Examples

Worked examples

Short, end-to-end recipes. All of them use the ECSI customer-satisfaction dataset that ships with the test suite (tests/data/satisfaction.csv); the structural model has 6 LVs and 27 indicators, with about 250 respondents.

If you have the repo checked out, the snippets run as is. Otherwise grab satisfaction.csv from the repo.

1. Customer satisfaction (the ECSI 6-LV model)

The canonical PLS-SEM example. Image, Expectations, Quality, Value feed into Satisfaction; Satisfaction and Image feed into Loyalty.

import pandas as pd
import openpls.config as c
from openpls import Plspm
from openpls.mode import Mode
from openpls.scheme import Scheme
satisfaction = pd.read_csv("tests/data/satisfaction.csv", index_col=0)
structure = c.Structure()
structure.add_path(["IMAG"], ["EXPE", "SAT", "LOY"])
structure.add_path(["EXPE"], ["QUAL", "VAL", "SAT"])
structure.add_path(["QUAL"], ["VAL", "SAT"])
structure.add_path(["VAL"], ["SAT"])
structure.add_path(["SAT"], ["LOY"])
config = c.Config(structure.path(), scaled=False)
for lv in ["IMAG", "EXPE", "QUAL", "VAL", "SAT", "LOY"]:
config.add_lv_with_columns_named(lv, Mode.A, satisfaction, lv.lower())
fit = Plspm(satisfaction, config, Scheme.CENTROID)
print("Path coefficients:")
print(fit.path_coefficients().round(3))
print("\nHTMT (discriminant validity, lower is better, threshold 0.85):")
print(fit.htmt().matrix().round(3))
print("\nModel fit:")
print(f"SRMR = {fit.model_fit().srmr():.4f}")
print(f"d_ULS = {fit.model_fit().d_uls():.4f}")

What to look for:

  • SAT and QUAL should both have R squared above 0.7 (this is a well-fitting textbook model).
  • HTMT values should mostly sit below 0.85.
  • SRMR is typically around 0.05 to 0.07 here, comfortably below the 0.08 conventional threshold.

2. IPMA on the satisfaction model

For the SAT target, IPMA tells you which predecessor LV has the best combination of high importance (large total effect on SAT) and low performance (low rescaled score), the candidates for management intervention.

ipma = fit.ipma(target="SAT", scale_min=1.0, scale_max=10.0)
print("LV-level importance and performance for SAT:")
print(ipma.latent_variables().round(3))
print("\nIndicator-level breakdown:")
print(ipma.indicators().round(3))

scale_min=1.0 and scale_max=10.0 match the ECSI 10-point Likert scale used in the data. If you omit both, each indicator is rescaled from its observed min and max instead.

The LV table shows importance (standardized total effect) and performance (mean of the 0-100-rescaled LV score). Plot performance against importance to get the four-quadrant IPMA chart: high importance plus low performance is the high-leverage quadrant.

3. Moderation: IMAG x EXPE -> SAT

Does the effect of Customer Expectations on Satisfaction depend on Image? Two-stage moderation fits a base model, then refits with the standardized product of IMAG and EXPE scores as a single-indicator interaction LV.

from openpls.moderation import Moderation
mod = Moderation(
satisfaction,
config,
predictor="EXPE",
moderator="IMAG",
target="SAT",
)
print(f"Interaction LV name: {mod.interaction_name}")
print("\nBase model paths into SAT:")
base_paths = mod.base().path_coefficients().loc["SAT"]
print(base_paths[base_paths != 0].round(3))
print("\nRefit paths into SAT (with interaction):")
refit_paths = mod.refit().path_coefficients().loc["SAT"]
print(refit_paths[refit_paths != 0].round(3))
print("\nInteraction effect (interaction -> SAT):")
print(mod.interaction_effect().round(4))

A non-zero estimate on the interaction path means the EXPE-to-SAT effect varies with the level of IMAG: when IMAG is high, EXPE pushes SAT harder (or weaker), depending on the sign. The OLS-derived t and p values in interaction_effect() are convenience reporting; for inference, bootstrap the refit yourself with LongBootstrap.

4. FIMIX-PLS segmentation

Are there hidden subgroups in the data whose structural paths differ from the pooled estimates? FIMIX runs EM with multiple random restarts to detect K mixture components.

fmx = fit.fimix(n_classes=3, n_restarts=5, seed=42)
print("Class sizes (mixture proportions):")
print(fmx.class_sizes().round(3))
print("\nHard class assignments (first 10 respondents):")
print(fmx.hard_assignments().head(10))
print("\nFit criteria (lower is better for AIC/BIC; higher is better for EN):")
print(fmx.fit_criteria().round(2))
print("\nClass-specific path coefficients (first 10 rows):")
print(fmx.class_paths().head(10).round(3))

To choose K, run FIMIX for K = 2, 3, 4, … and look at the BIC column from fit_criteria(): the K with the lowest BIC is usually the right answer. The normalized entropy EN should stay close to 1; if it drops below about 0.5, your classes are not well separated and the segmentation result is not trustworthy regardless of which IC says is best.

Once you have hard assignments, you can rerun the model on each subgroup to inspect the per-class structural model with full PLS-SEM reporting:

labels = fmx.hard_assignments()
for k in sorted(labels.unique()):
sub = satisfaction.loc[labels == k].reset_index(drop=True)
sub_fit = Plspm(sub, config, Scheme.CENTROID)
print(f"\n--- class {k}, n={len(sub)} ---")
print(sub_fit.path_coefficients().round(3))

5. Nomological validity

Do the LV correlations point in the theoretically expected directions? For the ECSI model, every path is hypothesised to be positive - image improves expectations, expectations improve quality, and so on down to loyalty. nomological_validity runs a one-sided t-test on each pair.

hypotheses = [
("IMAG", "EXPE", "+"),
("IMAG", "SAT", "+"),
("IMAG", "LOY", "+"),
("EXPE", "QUAL", "+"),
("EXPE", "VAL", "+"),
("EXPE", "SAT", "+"),
("QUAL", "VAL", "+"),
("QUAL", "SAT", "+"),
("VAL", "SAT", "+"),
("SAT", "LOY", "+"),
]
nv = fit.nomological_validity(hypotheses, alpha=0.05)
print("Per-hypothesis verdict:")
print(nv.table().round(3))
print("\nFull LV correlation matrix:")
print(nv.correlations().round(3))

The verdict column takes three values: "supported" (sign matches AND p_one_sided < 0.05), "rejected_wrong_sign" (correlation points the wrong way, which is the strongest possible falsification), and "not_significant" (correct direction but insufficient evidence).

6. Common-method bias (Lindell-Whitney)

Reviewers of survey-based studies routinely ask whether responses across constructs are inflated by shared method variance (all constructs rated by the same respondent on the same instrument). The Lindell-Whitney (2001) procedure needs one marker variable - a variable in your dataset that is theoretically unrelated to any substantive construct. The engine partial-correlates every LV pair by that marker’s smallest-absolute LV correlation and flags pairs that flip significance under adjustment.

import numpy as np
# For a real study, use a genuine marker (e.g. an unrelated single-item measure).
# Here we simulate one that is truly unrelated to the ECSI constructs.
rng = np.random.default_rng(42)
satisfaction_with_marker = satisfaction.assign(
MARKER=rng.standard_normal(len(satisfaction))
)
cmb = fit.cmb_lindell_whitney(satisfaction_with_marker["MARKER"], alpha=0.05)
print(f"r_M = {cmb.cmb_estimate():.4f}")
print("\nMarker correlations per LV:")
print(cmb.marker_correlations().round(4))
print("\nAdjusted vs unadjusted verdict per pair:")
print(cmb.table().round(3))

A large r_M (e.g. above 0.10 in absolute value) or a table with many "lost_significance" rows would suggest that observed inter-construct correlations are partially driven by common-method variance rather than true theoretical relationships.

7. CVPAT (Cross-Validated Predictive Ability Test)

PLSpredict tells you how well PLS predicts each indicator out of sample, but doesn’t test whether PLS is significantly better than a benchmark. CVPAT (Liengaard et al. 2021) closes that gap with a paired one-sided t-test on prediction losses.

cv = fit.cvpat(benchmark="IA", k=10, repeats=1, seed=42, alpha=0.05)
print("Overall verdict:")
print(cv.overall().round(4))
print("\nPer-endogenous-LV breakdown:")
print(cv.per_construct().round(4))

Verdict values are "pls_better" (PLS’s mean loss is significantly lower than the benchmark’s), "benchmark_better" (the benchmark wins), or "tie" (no significant difference). Two benchmarks are supported: "IA" (indicator average, the naive Q²_predict baseline) and "LM" (linear regression on direct antecedents of each target indicator). Run both to argue that PLS-SEM provides predictive relevance beyond either baseline.