py-flexplot 0.8.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyflexplot/sem.py ADDED
@@ -0,0 +1,195 @@
1
+ import pandas as pd
2
+ import numpy as np
3
+ from plotnine import (
4
+ aes,
5
+ element_text,
6
+ geom_hline,
7
+ geom_point,
8
+ geom_smooth,
9
+ geom_tile,
10
+ ggplot,
11
+ labs,
12
+ scale_fill_gradient2,
13
+ theme,
14
+ theme_bw,
15
+ theme_minimal,
16
+ )
17
+
18
+
19
+ def hopper_plot(model, **kwargs):
20
+ """
21
+ Ported from flexplavaan: Visualize residuals from the variance/covariance matrix.
22
+ Shows the discrepancy between observed and model-implied correlations.
23
+ """
24
+ try:
25
+ import semopy # noqa: F401 # availability check; model is caller-owned
26
+ except ImportError as exc:
27
+ raise ImportError("semopy not installed. Please install it to use SEM visualization.") from exc
28
+
29
+ # Observed covariance matrix (numpy array in current semopy).
30
+ obs_cov = getattr(model, "mx_cov", None)
31
+ if obs_cov is None:
32
+ raise AttributeError(
33
+ "Model does not expose mx_cov (observed covariance matrix)."
34
+ )
35
+ obs_cov = np.asarray(obs_cov)
36
+
37
+ # Model-implied covariance matrix.
38
+ if hasattr(model, "calc_sigma"):
39
+ sigma = model.calc_sigma()
40
+ # In semopy >= 2.3 calc_sigma returns a tuple; take the first array.
41
+ if isinstance(sigma, tuple):
42
+ sigma = sigma[0]
43
+ imp_cov = np.asarray(sigma)
44
+ else:
45
+ raise AttributeError(
46
+ "Model does not have a calc_sigma() method for implied covariance."
47
+ )
48
+
49
+ if obs_cov.shape != imp_cov.shape:
50
+ raise ValueError(
51
+ f"Observed and implied covariance matrices have different shapes: "
52
+ f"{obs_cov.shape} vs {imp_cov.shape}"
53
+ )
54
+
55
+ # Variable names: semopy stores observed variable names in model.vars['observed'].
56
+ vars_obs = getattr(model, "vars", {}).get("observed")
57
+ if vars_obs is None or len(vars_obs) != obs_cov.shape[0]:
58
+ vars_obs = [f"var{i}" for i in range(obs_cov.shape[0])]
59
+
60
+ # Calculate residuals (Observed - Implied)
61
+ res_cov = obs_cov - imp_cov
62
+
63
+ # Flatten lower triangle for plotting.
64
+ data_list = []
65
+ for i, row in enumerate(vars_obs):
66
+ for j, col in enumerate(vars_obs):
67
+ if i >= j: # lower triangle
68
+ data_list.append({
69
+ "var1": row,
70
+ "var2": col,
71
+ "residual": res_cov[i, j],
72
+ })
73
+
74
+ df_res = pd.DataFrame(data_list)
75
+
76
+ p = (
77
+ ggplot(df_res, aes(x="var1", y="var2", fill="residual"))
78
+ + geom_tile()
79
+ + scale_fill_gradient2(low="red", mid="white", high="blue")
80
+ + theme_minimal()
81
+ + theme(axis_text_x=element_text(rotation=45, hjust=1))
82
+ + labs(title="Hopper Plot (Covariance Residuals)")
83
+ )
84
+
85
+ return p
86
+
87
+
88
+ def disturbance_plot(model, var1: str, var2: str, data: pd.DataFrame):
89
+ """
90
+ Ported from flexplavaan: Visualize association between two variables
91
+ after removing model-implied fit.
92
+ """
93
+ if not isinstance(data, pd.DataFrame):
94
+ raise TypeError(f"data must be a pandas DataFrame, got {type(data).__name__}")
95
+ if data.empty:
96
+ raise ValueError("data must be non-empty for disturbance_plot.")
97
+
98
+ for var in (var1, var2):
99
+ if var not in data.columns:
100
+ raise ValueError(f"Variable {var!r} not found in data.")
101
+
102
+ if not hasattr(model, "predict"):
103
+ raise AttributeError("Model has no predict method.")
104
+
105
+ preds = model.predict(data)
106
+ if not isinstance(preds, pd.DataFrame):
107
+ raise TypeError(
108
+ f"model.predict must return a DataFrame, got {type(preds).__name__}"
109
+ )
110
+
111
+ missing_cols = {var1, var2} - set(preds.columns)
112
+ if missing_cols:
113
+ raise ValueError(
114
+ f"Predictions missing columns required for disturbance_plot: {sorted(missing_cols)}"
115
+ )
116
+
117
+ # Align predictions to the input data and validate length.
118
+ preds = preds.reindex(data.index)
119
+ valid = preds[[var1, var2]].notna().all(axis=1) & data[[var1, var2]].notna().all(axis=1)
120
+ if not valid.any():
121
+ raise ValueError(
122
+ "No observations remain after aligning predictions with data."
123
+ )
124
+
125
+ res1 = data.loc[valid, var1] - preds.loc[valid, var1]
126
+ res2 = data.loc[valid, var2] - preds.loc[valid, var2]
127
+
128
+ df_res = pd.DataFrame({
129
+ "res1": res1,
130
+ "res2": res2,
131
+ })
132
+
133
+ p = (
134
+ ggplot(df_res, aes(x="res1", y="res2"))
135
+ + geom_point(alpha=0.4)
136
+ + geom_smooth(method="loess", color="blue")
137
+ + geom_hline(yintercept=0, color="red", linetype="dashed")
138
+ + theme_bw()
139
+ + labs(
140
+ x=f"Residual {var1}",
141
+ y=f"Residual {var2}",
142
+ title=f"Disturbance Dependence: {var1} & {var2}",
143
+ )
144
+ )
145
+
146
+ return p
147
+
148
+
149
+ def measurement_plot(model, latent_var: str, indicator: str, data: pd.DataFrame):
150
+ """
151
+ Visualize relationship between a latent variable and one of its indicators.
152
+ """
153
+ if not isinstance(data, pd.DataFrame):
154
+ raise TypeError(f"data must be a pandas DataFrame, got {type(data).__name__}")
155
+ if data.empty:
156
+ raise ValueError("data must be non-empty for measurement_plot.")
157
+ if indicator not in data.columns:
158
+ raise ValueError(f"Indicator {indicator!r} not found in data.")
159
+
160
+ if not hasattr(model, "predict_factors"):
161
+ raise AttributeError("Model has no predict_factors method.")
162
+
163
+ factors = model.predict_factors(data)
164
+ if not isinstance(factors, pd.DataFrame):
165
+ raise TypeError(
166
+ f"model.predict_factors must return a DataFrame, got {type(factors).__name__}"
167
+ )
168
+ if latent_var not in factors.columns:
169
+ raise ValueError(
170
+ f"Latent variable {latent_var!r} not found in model factor predictions."
171
+ )
172
+
173
+ # Align factor scores with the input data.
174
+ factors = factors.reindex(data.index)
175
+ valid = factors[latent_var].notna() & data[indicator].notna()
176
+ if not valid.any():
177
+ raise ValueError(
178
+ "No observations remain after aligning factor scores with data."
179
+ )
180
+
181
+ df_merged = pd.concat(
182
+ [data.loc[valid, [indicator]], factors.loc[valid, [latent_var]]],
183
+ join="inner",
184
+ axis=1,
185
+ )
186
+
187
+ p = (
188
+ ggplot(df_merged, aes(x=latent_var, y=indicator))
189
+ + geom_point(alpha=0.5)
190
+ + geom_smooth(method="lm", color="blue")
191
+ + theme_bw()
192
+ + labs(title=f"Measurement Plot: {latent_var} -> {indicator}")
193
+ )
194
+
195
+ return p