gomd 2.1.0__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.
- gomd/__init__.py +380 -0
- gomd/__main__.py +7 -0
- gomd/cli.py +76 -0
- gomd/client.py +1372 -0
- gomd/dynamics.py +629 -0
- gomd/maritime.py +952 -0
- gomd/ocean.py +1646 -0
- gomd/spatial.py +222 -0
- gomd/stats.py +1086 -0
- gomd-2.1.0.dist-info/METADATA +277 -0
- gomd-2.1.0.dist-info/RECORD +15 -0
- gomd-2.1.0.dist-info/WHEEL +5 -0
- gomd-2.1.0.dist-info/entry_points.txt +2 -0
- gomd-2.1.0.dist-info/licenses/LICENSE +21 -0
- gomd-2.1.0.dist-info/top_level.txt +1 -0
gomd/stats.py
ADDED
|
@@ -0,0 +1,1086 @@
|
|
|
1
|
+
"""
|
|
2
|
+
gomd.stats — Time-Series Econometrics, Non-Parametric Trend Detection,
|
|
3
|
+
Regime-Shift Analysis, Extreme Value Theory (GEV/Gumbel), and Panel Estimators
|
|
4
|
+
===============================================================================
|
|
5
|
+
Implements self-contained statistical & econometric algorithms:
|
|
6
|
+
1. Mann-Kendall Monotonic Trend Test & Theil-Sen Robust Slope Estimator
|
|
7
|
+
2. Pettitt (1979) Non-Parametric Regime-Shift / Change-Point Detection
|
|
8
|
+
3. Gumbel / Extreme Value Return Level Estimator (10, 25, 50, 100-yr periods)
|
|
9
|
+
4. Engle-Granger Two-Step Cointegration & Error Correction Mechanism (ECM)
|
|
10
|
+
5. Within-Transformation Fixed-Effects (FE) Panel Regression Estimator
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
import statistics
|
|
17
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _normal_cdf(z: float) -> float:
|
|
21
|
+
return 0.5 * (1.0 + math.erf(z / math.sqrt(2.0)))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def mann_kendall_sen(
|
|
25
|
+
y: Sequence[float],
|
|
26
|
+
t: Optional[Sequence[float]] = None,
|
|
27
|
+
) -> Dict[str, Any]:
|
|
28
|
+
"""
|
|
29
|
+
Perform the non-parametric Mann-Kendall monotonic trend test (Mann, 1945;
|
|
30
|
+
Kendall, 1975) with tie-group variance correction and compute the Theil-Sen
|
|
31
|
+
robust slope estimator (Sen, 1968).
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
y : Sequence[float]
|
|
36
|
+
Time-series observations.
|
|
37
|
+
t : Sequence[float], optional
|
|
38
|
+
Time coordinates (e.g., years). Defaults to `0, 1, ..., n-1`.
|
|
39
|
+
"""
|
|
40
|
+
vals = [float(v) for v in y if v is not None and not math.isnan(float(v))]
|
|
41
|
+
n = len(vals)
|
|
42
|
+
if n < 3:
|
|
43
|
+
return {"error": "At least 3 observations are required for Mann-Kendall test.", "n": n}
|
|
44
|
+
|
|
45
|
+
times = [float(x) for x in t] if (t is not None and len(t) == n) else [float(i) for i in range(n)]
|
|
46
|
+
|
|
47
|
+
s_stat = 0
|
|
48
|
+
slopes: List[float] = []
|
|
49
|
+
for i in range(n - 1):
|
|
50
|
+
for j in range(i + 1, n):
|
|
51
|
+
diff = vals[j] - vals[i]
|
|
52
|
+
if diff > 0:
|
|
53
|
+
s_stat += 1
|
|
54
|
+
elif diff < 0:
|
|
55
|
+
s_stat -= 1
|
|
56
|
+
dt = times[j] - times[i]
|
|
57
|
+
if dt != 0:
|
|
58
|
+
slopes.append(diff / dt)
|
|
59
|
+
|
|
60
|
+
# Tie correction for Var(S)
|
|
61
|
+
counts: Dict[float, int] = {}
|
|
62
|
+
for v in vals:
|
|
63
|
+
counts[v] = counts.get(v, 0) + 1
|
|
64
|
+
tie_term = sum(m * (m - 1) * (2 * m + 5) for m in counts.values() if m > 1)
|
|
65
|
+
var_s = (n * (n - 1) * (2 * n + 5) - tie_term) / 18.0
|
|
66
|
+
std_s = math.sqrt(max(var_s, 1e-9))
|
|
67
|
+
|
|
68
|
+
if s_stat > 0:
|
|
69
|
+
z_mk = (s_stat - 1.0) / std_s
|
|
70
|
+
elif s_stat < 0:
|
|
71
|
+
z_mk = (s_stat + 1.0) / std_s
|
|
72
|
+
else:
|
|
73
|
+
z_mk = 0.0
|
|
74
|
+
|
|
75
|
+
p_val = 2.0 * (1.0 - _normal_cdf(abs(z_mk)))
|
|
76
|
+
tau = s_stat / (0.5 * n * (n - 1))
|
|
77
|
+
sen_slope = statistics.median(slopes) if slopes else 0.0
|
|
78
|
+
sen_intercept = statistics.median(vals) - sen_slope * statistics.median(times)
|
|
79
|
+
|
|
80
|
+
if p_val < 0.05:
|
|
81
|
+
direction = "increasing_significant" if z_mk > 0 else "decreasing_significant"
|
|
82
|
+
else:
|
|
83
|
+
direction = "no_significant_trend"
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
"n": n,
|
|
87
|
+
"mann_kendall_s": s_stat,
|
|
88
|
+
"kendall_tau": round(tau, 4),
|
|
89
|
+
"z_stat": round(z_mk, 4),
|
|
90
|
+
"p_value": round(p_val, 6),
|
|
91
|
+
"theil_sen_slope": round(sen_slope, 6),
|
|
92
|
+
"sen_slope_per_unit": round(sen_slope, 6),
|
|
93
|
+
"theil_sen_intercept": round(sen_intercept, 4),
|
|
94
|
+
"trend_classification": direction,
|
|
95
|
+
"trend": direction,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def pettitt_changepoint(
|
|
100
|
+
y: Sequence[float],
|
|
101
|
+
labels: Optional[Sequence[Any]] = None,
|
|
102
|
+
) -> Dict[str, Any]:
|
|
103
|
+
"""
|
|
104
|
+
Detect a single structural regime shift (change-point) in a time series using
|
|
105
|
+
the non-parametric Pettitt (1979, Applied Statistics) rank-based test.
|
|
106
|
+
"""
|
|
107
|
+
vals = [float(v) for v in y if v is not None and not math.isnan(float(v))]
|
|
108
|
+
n = len(vals)
|
|
109
|
+
if n < 4:
|
|
110
|
+
return {"error": "At least 4 observations are required for Pettitt test.", "n": n}
|
|
111
|
+
|
|
112
|
+
idx_labels = list(labels) if (labels is not None and len(labels) == n) else list(range(n))
|
|
113
|
+
|
|
114
|
+
u_t: List[int] = []
|
|
115
|
+
u_curr = 0
|
|
116
|
+
for t_idx in range(n - 1):
|
|
117
|
+
# Incremental update of Mann-Whitney U_t,n
|
|
118
|
+
delta = 0
|
|
119
|
+
v_t = vals[t_idx]
|
|
120
|
+
for j in range(n):
|
|
121
|
+
if vals[j] > v_t:
|
|
122
|
+
delta += 1
|
|
123
|
+
elif vals[j] < v_t:
|
|
124
|
+
delta -= 1
|
|
125
|
+
u_curr += delta
|
|
126
|
+
u_t.append(abs(u_curr))
|
|
127
|
+
|
|
128
|
+
k_max = max(u_t)
|
|
129
|
+
tau_idx = u_t.index(k_max)
|
|
130
|
+
# Pettitt (1979) approximate two-sided significance probability
|
|
131
|
+
p_approx = min(2.0 * math.exp((-6.0 * (k_max ** 2)) / ((n ** 3) + (n ** 2))), 1.0)
|
|
132
|
+
|
|
133
|
+
pre_mean = sum(vals[: tau_idx + 1]) / (tau_idx + 1)
|
|
134
|
+
post_mean = sum(vals[tau_idx + 1 :]) / max(n - tau_idx - 1, 1)
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
"n": n,
|
|
138
|
+
"changepoint_index": tau_idx,
|
|
139
|
+
"changepoint_label": idx_labels[tau_idx],
|
|
140
|
+
"pettitt_k_stat": k_max,
|
|
141
|
+
"p_value_approx": round(p_approx, 6),
|
|
142
|
+
"significant_at_5pct": p_approx < 0.05,
|
|
143
|
+
"pre_regime_mean": round(pre_mean, 4),
|
|
144
|
+
"post_regime_mean": round(post_mean, 4),
|
|
145
|
+
"mean_before": round(pre_mean, 4),
|
|
146
|
+
"mean_after": round(post_mean, 4),
|
|
147
|
+
"regime_shift_delta": round(post_mean - pre_mean, 4),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def extreme_return_levels(
|
|
152
|
+
annual_maxima: Sequence[float],
|
|
153
|
+
return_periods: Sequence[int] = (10, 25, 50, 100),
|
|
154
|
+
) -> Dict[str, Any]:
|
|
155
|
+
"""
|
|
156
|
+
Fit an Extreme Value Type I (Gumbel) distribution via L-moments / Euler-Mascheroni
|
|
157
|
+
method of moments to annual maxima and compute extreme return levels z_T for
|
|
158
|
+
specified return periods T (in years).
|
|
159
|
+
"""
|
|
160
|
+
vals = [float(v) for v in annual_maxima if v is not None and not math.isnan(float(v))]
|
|
161
|
+
n = len(vals)
|
|
162
|
+
if n < 5:
|
|
163
|
+
return {"error": "At least 5 annual maxima are required for Gumbel fitting.", "n": n}
|
|
164
|
+
|
|
165
|
+
mean_val = sum(vals) / n
|
|
166
|
+
var_val = sum((v - mean_val) ** 2 for v in vals) / (n - 1)
|
|
167
|
+
std_val = math.sqrt(max(var_val, 1e-9))
|
|
168
|
+
|
|
169
|
+
# Euler-Mascheroni constant gamma_E = 0.5772156649
|
|
170
|
+
gamma_e = 0.5772156649015329
|
|
171
|
+
beta_scale = (std_val * math.sqrt(6.0)) / math.pi
|
|
172
|
+
mu_loc = mean_val - gamma_e * beta_scale
|
|
173
|
+
|
|
174
|
+
levels: Dict[str, float] = {}
|
|
175
|
+
for rp in return_periods:
|
|
176
|
+
T = max(float(rp), 1.001)
|
|
177
|
+
y_t = -math.log(-math.log(1.0 - 1.0 / T))
|
|
178
|
+
z_t = mu_loc + beta_scale * y_t
|
|
179
|
+
levels[f"return_level_{int(rp)}yr"] = round(z_t, 4)
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
"n_years": n,
|
|
183
|
+
"sample_mean": round(mean_val, 4),
|
|
184
|
+
"sample_std": round(std_val, 4),
|
|
185
|
+
"gumbel_location_mu": round(mu_loc, 4),
|
|
186
|
+
"gumbel_scale_beta": round(beta_scale, 4),
|
|
187
|
+
"return_levels": levels,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def cointegration_eg_test(
|
|
192
|
+
x: Sequence[float],
|
|
193
|
+
y: Sequence[float],
|
|
194
|
+
) -> Dict[str, Any]:
|
|
195
|
+
"""
|
|
196
|
+
Execute the Engle & Granger (1987) two-step cointegration analysis and estimate
|
|
197
|
+
the Error Correction Mechanism (ECM) speed-of-adjustment coefficient:
|
|
198
|
+
Step 1 (Long-run equilibrium): y_t = alpha + beta * x_t + u_t
|
|
199
|
+
Step 2 (Residual ADF test): Delta u_t = phi * u_{t-1} + eps_t
|
|
200
|
+
Step 3 (ECM): Delta y_t = c + gamma * u_{t-1} + delta * Delta x_t
|
|
201
|
+
"""
|
|
202
|
+
n = min(len(x), len(y))
|
|
203
|
+
if n < 8:
|
|
204
|
+
return {"error": "At least 8 paired observations are required for Engle-Granger test.", "n": n}
|
|
205
|
+
|
|
206
|
+
xv = [float(x[i]) for i in range(n)]
|
|
207
|
+
yv = [float(y[i]) for i in range(n)]
|
|
208
|
+
|
|
209
|
+
mx = sum(xv) / n
|
|
210
|
+
my = sum(yv) / n
|
|
211
|
+
sxx = sum((xv[i] - mx) ** 2 for i in range(n))
|
|
212
|
+
sxy = sum((xv[i] - mx) * (yv[i] - my) for i in range(n))
|
|
213
|
+
beta = sxy / max(sxx, 1e-12)
|
|
214
|
+
alpha = my - beta * mx
|
|
215
|
+
|
|
216
|
+
# Cointegrating residuals u_t
|
|
217
|
+
u = [yv[i] - (alpha + beta * xv[i]) for i in range(n)]
|
|
218
|
+
du = [u[t] - u[t - 1] for t in range(1, n)]
|
|
219
|
+
u_lag = [u[t - 1] for t in range(1, n)]
|
|
220
|
+
|
|
221
|
+
denom_u = sum(val ** 2 for val in u_lag)
|
|
222
|
+
phi = sum(u_lag[i] * du[i] for i in range(n - 1)) / max(denom_u, 1e-12)
|
|
223
|
+
resid_adf = [du[i] - phi * u_lag[i] for i in range(n - 1)]
|
|
224
|
+
sigma2_adf = sum(r ** 2 for r in resid_adf) / max(n - 2, 1)
|
|
225
|
+
se_phi = math.sqrt(sigma2_adf / max(denom_u, 1e-12))
|
|
226
|
+
adf_t_stat = phi / max(se_phi, 1e-12)
|
|
227
|
+
|
|
228
|
+
# MacKinnon (1991) 5% critical value for 2-variable Engle-Granger cointegration ~ -3.34
|
|
229
|
+
cointegrated_5pct = adf_t_stat < -3.34
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
"n": n,
|
|
233
|
+
"long_run_intercept_alpha": round(alpha, 4),
|
|
234
|
+
"long_run_slope_beta": round(beta, 6),
|
|
235
|
+
"adf_residual_rho": round(phi, 4),
|
|
236
|
+
"engle_granger_adf_t_stat": round(adf_t_stat, 4),
|
|
237
|
+
"critical_value_5pct": -3.34,
|
|
238
|
+
"cointegrated_at_5pct": cointegrated_5pct,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def panel_fixed_effects(
|
|
243
|
+
records: Sequence[Dict[str, Any]],
|
|
244
|
+
entity_col: str,
|
|
245
|
+
y_col: str,
|
|
246
|
+
x_col: str,
|
|
247
|
+
) -> Dict[str, Any]:
|
|
248
|
+
"""
|
|
249
|
+
Estimate a Within-Transformation One-Way Fixed Effects (FE) panel regression:
|
|
250
|
+
(y_{it} - y_bar_i) = beta_FE * (x_{it} - x_bar_i) + (eps_{it} - eps_bar_i)
|
|
251
|
+
|
|
252
|
+
Controls for all time-invariant unobserved heterogeneity alpha_i across countries
|
|
253
|
+
or sea basins without requiring external econometrics libraries.
|
|
254
|
+
"""
|
|
255
|
+
groups: Dict[str, List[Tuple[float, float]]] = {}
|
|
256
|
+
for row in records:
|
|
257
|
+
if entity_col in row and y_col in row and x_col in row:
|
|
258
|
+
try:
|
|
259
|
+
ent = str(row[entity_col])
|
|
260
|
+
yv = float(row[y_col])
|
|
261
|
+
xv = float(row[x_col])
|
|
262
|
+
if not (math.isnan(yv) or math.isnan(xv)):
|
|
263
|
+
groups.setdefault(ent, []).append((xv, yv))
|
|
264
|
+
except (TypeError, ValueError):
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
x_tilde: List[float] = []
|
|
268
|
+
y_tilde: List[float] = []
|
|
269
|
+
n_entities = 0
|
|
270
|
+
for ent, pairs in groups.items():
|
|
271
|
+
if len(pairs) < 2:
|
|
272
|
+
continue
|
|
273
|
+
n_entities += 1
|
|
274
|
+
mx = sum(p[0] for p in pairs) / len(pairs)
|
|
275
|
+
my = sum(p[1] for p in pairs) / len(pairs)
|
|
276
|
+
for xv, yv in pairs:
|
|
277
|
+
x_tilde.append(xv - mx)
|
|
278
|
+
y_tilde.append(yv - my)
|
|
279
|
+
|
|
280
|
+
n_obs = len(x_tilde)
|
|
281
|
+
if n_obs < 4 or n_entities < 1:
|
|
282
|
+
return {"error": "Insufficient panel observations after within-transformation.", "n_obs": n_obs}
|
|
283
|
+
|
|
284
|
+
sxx = sum(x ** 2 for x in x_tilde)
|
|
285
|
+
sxy = sum(x_tilde[i] * y_tilde[i] for i in range(n_obs))
|
|
286
|
+
syy = sum(y ** 2 for y in y_tilde)
|
|
287
|
+
beta_fe = sxy / max(sxx, 1e-12)
|
|
288
|
+
|
|
289
|
+
rss = sum((y_tilde[i] - beta_fe * x_tilde[i]) ** 2 for i in range(n_obs))
|
|
290
|
+
dof = max(n_obs - n_entities - 1, 1)
|
|
291
|
+
sigma2 = rss / dof
|
|
292
|
+
se_beta = math.sqrt(sigma2 / max(sxx, 1e-12))
|
|
293
|
+
t_stat = beta_fe / max(se_beta, 1e-12)
|
|
294
|
+
p_val = 2.0 * (1.0 - _normal_cdf(abs(t_stat)))
|
|
295
|
+
r2_within = max(1.0 - rss / max(syy, 1e-12), 0.0)
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
"n_obs": n_obs,
|
|
299
|
+
"n_entities": n_entities,
|
|
300
|
+
"degrees_of_freedom": dof,
|
|
301
|
+
"beta_fe": round(beta_fe, 6),
|
|
302
|
+
"std_error": round(se_beta, 6),
|
|
303
|
+
"t_stat": round(t_stat, 4),
|
|
304
|
+
"p_value": round(p_val, 6),
|
|
305
|
+
"r_squared_within": round(r2_within, 4),
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def synthetic_did_ate(
|
|
310
|
+
treated_pre: Sequence[float],
|
|
311
|
+
treated_post: Sequence[float],
|
|
312
|
+
control_matrix_pre: Sequence[Sequence[float]],
|
|
313
|
+
control_matrix_post: Sequence[Sequence[float]],
|
|
314
|
+
ridge_zeta: float = 0.05,
|
|
315
|
+
) -> Dict[str, Any]:
|
|
316
|
+
"""
|
|
317
|
+
Estimate the Causal Average Treatment Effect on the Treated (ATE / ATT) of a Marine
|
|
318
|
+
or Climate Policy Intervention using Synthetic Difference-in-Differences (SDID).
|
|
319
|
+
|
|
320
|
+
Combines Abadie Synthetic Control unit weights (omega_i) and time weights (lambda_t)
|
|
321
|
+
with two-way fixed effects to achieve doubly-robust causal inference even when
|
|
322
|
+
parallel trends are imperfect.
|
|
323
|
+
|
|
324
|
+
Post-2020 Econometric Reference:
|
|
325
|
+
- Arkhangelsky, D., Athey, S., Hirshberg, D. A., Imbens, G. W., & Wager, S. (2021).
|
|
326
|
+
Synthetic Difference-in-Differences. American Economic Review, 111(12), 4088-4118.
|
|
327
|
+
"""
|
|
328
|
+
t_pre = [float(v) for v in treated_pre]
|
|
329
|
+
t_post = [float(v) for v in treated_post]
|
|
330
|
+
c_pre = [[float(v) for v in row] for row in control_matrix_pre]
|
|
331
|
+
c_post = [[float(v) for v in row] for row in control_matrix_post]
|
|
332
|
+
|
|
333
|
+
n_controls = len(c_pre)
|
|
334
|
+
t0 = len(t_pre)
|
|
335
|
+
t1 = len(t_post)
|
|
336
|
+
if n_controls < 1 or t0 < 2 or t1 < 1:
|
|
337
|
+
raise ValueError("synthetic_did_ate requires >=1 control unit, >=2 pre-periods, and >=1 post-period.")
|
|
338
|
+
|
|
339
|
+
# 1. Compute unit weights omega_i via regularized pre-treatment trajectory distance
|
|
340
|
+
# (exponential softmax over inverse L2 pre-trend MSE after level-demean)
|
|
341
|
+
t_pre_mean = sum(t_pre) / t0
|
|
342
|
+
t_pre_centered = [y - t_pre_mean for y in t_pre]
|
|
343
|
+
|
|
344
|
+
inv_scores: List[float] = []
|
|
345
|
+
for j in range(n_controls):
|
|
346
|
+
row = c_pre[j][:t0]
|
|
347
|
+
m_j = sum(row) / len(row)
|
|
348
|
+
mse = sum(((row[t] - m_j) - t_pre_centered[t]) ** 2 for t in range(t0)) / t0
|
|
349
|
+
inv_scores.append(math.exp(-mse / max(float(ridge_zeta), 1e-4)))
|
|
350
|
+
|
|
351
|
+
sum_inv = sum(inv_scores)
|
|
352
|
+
omega = [w / sum_inv if sum_inv > 0 else (1.0 / n_controls) for w in inv_scores]
|
|
353
|
+
|
|
354
|
+
# 2. Compute time weights lambda_t matching control post-treatment average to pre-treatment steps
|
|
355
|
+
c_post_means = [sum(c_post[j]) / len(c_post[j]) for j in range(n_controls)]
|
|
356
|
+
time_scores: List[float] = []
|
|
357
|
+
for t in range(t0):
|
|
358
|
+
err_t = sum(omega[j] * ((c_pre[j][t] - c_post_means[j]) ** 2) for j in range(n_controls))
|
|
359
|
+
time_scores.append(math.exp(-err_t / max(float(ridge_zeta) * 2.0, 1e-4)))
|
|
360
|
+
sum_t = sum(time_scores)
|
|
361
|
+
lambd = [lt / sum_t if sum_t > 0 else (1.0 / t0) for lt in time_scores]
|
|
362
|
+
|
|
363
|
+
# 3. Compute weighted pre/post averages for treated and synthetic control
|
|
364
|
+
y_tr_pre_w = sum(lambd[t] * t_pre[t] for t in range(t0))
|
|
365
|
+
y_tr_post = sum(t_post) / t1
|
|
366
|
+
|
|
367
|
+
y_sc_pre_w = sum(
|
|
368
|
+
omega[j] * sum(lambd[t] * c_pre[j][t] for t in range(t0))
|
|
369
|
+
for j in range(n_controls)
|
|
370
|
+
)
|
|
371
|
+
y_sc_post = sum(omega[j] * (sum(c_post[j]) / len(c_post[j])) for j in range(n_controls))
|
|
372
|
+
|
|
373
|
+
# Standard unweighted DiD vs SDID estimator
|
|
374
|
+
unweighted_sc_pre = sum(sum(c_pre[j]) / t0 for j in range(n_controls)) / n_controls
|
|
375
|
+
unweighted_sc_post = sum(sum(c_post[j]) / len(c_post[j]) for j in range(n_controls)) / n_controls
|
|
376
|
+
did_classic = (y_tr_post - t_pre_mean) - (unweighted_sc_post - unweighted_sc_pre)
|
|
377
|
+
sdid_att = (y_tr_post - y_tr_pre_w) - (y_sc_post - y_sc_pre_w)
|
|
378
|
+
|
|
379
|
+
# Pre-treatment root mean squared prediction error (RMSPE)
|
|
380
|
+
pre_rmspe = math.sqrt(
|
|
381
|
+
sum(
|
|
382
|
+
(
|
|
383
|
+
(t_pre[t] - y_tr_pre_w)
|
|
384
|
+
- (sum(omega[j] * c_pre[j][t] for j in range(n_controls)) - y_sc_pre_w)
|
|
385
|
+
)
|
|
386
|
+
** 2
|
|
387
|
+
for t in range(t0)
|
|
388
|
+
)
|
|
389
|
+
/ t0
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
"sdid_att_estimate": round(sdid_att, 5),
|
|
394
|
+
"classic_did_estimate": round(did_classic, 5),
|
|
395
|
+
"treated_post_mean": round(y_tr_post, 4),
|
|
396
|
+
"counterfactual_post_mean": round(y_tr_post - sdid_att, 4),
|
|
397
|
+
"pre_treatment_rmspe": round(pre_rmspe, 5),
|
|
398
|
+
"unit_weights_omega": [round(w, 4) for w in omega],
|
|
399
|
+
"time_weights_lambda": [round(lt, 4) for lt in lambd],
|
|
400
|
+
"reference": "Arkhangelsky et al. (American Economic Review, 2021) Synthetic DiD",
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def innovative_polygon_trend(
|
|
405
|
+
y: Sequence[float],
|
|
406
|
+
) -> Dict[str, Any]:
|
|
407
|
+
"""
|
|
408
|
+
Compute Şen's Innovative Trend Analysis (ITA) & Post-2020 Sub-Regime Quantile
|
|
409
|
+
Trend Decomposition (Low, Medium, Extreme High Clusters).
|
|
410
|
+
|
|
411
|
+
Splits the series into two equal halves, sorts both halves in ascending order,
|
|
412
|
+
projects onto the 1:1 Cartesian line, and evaluates whether extreme highs
|
|
413
|
+
are accelerating faster than central tendencies.
|
|
414
|
+
|
|
415
|
+
Post-2020 Hydro-Climatological References:
|
|
416
|
+
- Şen, Z. (2020, 2021). Innovative polygon trend analysis (IPTA) and applications.
|
|
417
|
+
Journal of Hydrology / Theoretical and Applied Climatology.
|
|
418
|
+
"""
|
|
419
|
+
vals = [float(v) for v in y if v is not None and not math.isnan(float(v))]
|
|
420
|
+
n = len(vals)
|
|
421
|
+
if n < 6:
|
|
422
|
+
raise ValueError("innovative_polygon_trend requires at least 6 observations.")
|
|
423
|
+
|
|
424
|
+
half = n // 2
|
|
425
|
+
first_half = sorted(vals[:half])
|
|
426
|
+
second_half = sorted(vals[-half:])
|
|
427
|
+
|
|
428
|
+
m1 = sum(first_half) / half
|
|
429
|
+
m2 = sum(second_half) / half
|
|
430
|
+
s_ita = (2.0 * (m2 - m1)) / float(n)
|
|
431
|
+
d_indicator = ((m2 - m1) / max(abs(m1), 1e-9)) * 100.0
|
|
432
|
+
|
|
433
|
+
# Partition sorted pairs into Low (bottom 1/3), Medium (middle 1/3), Extreme High (top 1/3)
|
|
434
|
+
k1 = max(1, half // 3)
|
|
435
|
+
k2 = max(k1 + 1, (2 * half) // 3)
|
|
436
|
+
low_diff = sum(second_half[i] - first_half[i] for i in range(0, k1)) / k1
|
|
437
|
+
med_diff = sum(second_half[i] - first_half[i] for i in range(k1, k2)) / max(k2 - k1, 1)
|
|
438
|
+
high_diff = sum(second_half[i] - first_half[i] for i in range(k2, half)) / max(half - k2, 1)
|
|
439
|
+
|
|
440
|
+
return {
|
|
441
|
+
"n_used": 2 * half,
|
|
442
|
+
"first_half_mean": round(m1, 4),
|
|
443
|
+
"second_half_mean": round(m2, 4),
|
|
444
|
+
"ita_slope_per_step": round(s_ita, 6),
|
|
445
|
+
"ita_relative_change_pct": round(d_indicator, 2),
|
|
446
|
+
"subregime_shifts": {
|
|
447
|
+
"low_quantile_shift": round(low_diff, 4),
|
|
448
|
+
"medium_quantile_shift": round(med_diff, 4),
|
|
449
|
+
"extreme_high_quantile_shift": round(high_diff, 4),
|
|
450
|
+
},
|
|
451
|
+
"tail_amplification_ratio": round(high_diff / med_diff, 3) if abs(med_diff) > 1e-6 else None,
|
|
452
|
+
"reference": "Şen (2020, 2021) Innovative Polygon & Sub-Regime Quantile Trend Analysis",
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def transfer_entropy_causal(
|
|
457
|
+
x: Sequence[float],
|
|
458
|
+
y: Sequence[float],
|
|
459
|
+
bins: int = 3,
|
|
460
|
+
) -> Dict[str, Any]:
|
|
461
|
+
"""
|
|
462
|
+
Compute Information-Theoretic Shannon Transfer Entropy (T_{X->Y} and T_{Y->X} in bits)
|
|
463
|
+
and Net Directed Causal Information Flow between Two Climate/Ocean Series.
|
|
464
|
+
|
|
465
|
+
Post-2020 Scientific References:
|
|
466
|
+
- Runge, J., Bathiany, S., Bollt, E., Camps-Valls, G., Coumou, D., Deyle, E., ... &
|
|
467
|
+
Zscheischler, J. (2019 / 2023). Inferring causation from time series in Earth
|
|
468
|
+
system sciences. Nature Communications, 10(1), 2553; Nature Reviews Earth &
|
|
469
|
+
Environment (2023).
|
|
470
|
+
|
|
471
|
+
Formula:
|
|
472
|
+
T_{X -> Y} = sum p(y_t, y_{t-1}, x_{t-1}) * log2( p(y_t | y_{t-1}, x_{t-1}) / p(y_t | y_{t-1}) )
|
|
473
|
+
"""
|
|
474
|
+
xv = [float(v) for v in x]
|
|
475
|
+
yv = [float(v) for v in y]
|
|
476
|
+
n = min(len(xv), len(yv))
|
|
477
|
+
if n < 8:
|
|
478
|
+
raise ValueError("transfer_entropy_causal requires at least 8 paired observations.")
|
|
479
|
+
|
|
480
|
+
def _quantize(arr: List[float], b: int) -> List[int]:
|
|
481
|
+
s = sorted(arr)
|
|
482
|
+
cuts = [s[int((k / float(b)) * (len(s) - 1))] for k in range(1, b)]
|
|
483
|
+
out: List[int] = []
|
|
484
|
+
for val in arr:
|
|
485
|
+
idx = 0
|
|
486
|
+
for c in cuts:
|
|
487
|
+
if val > c:
|
|
488
|
+
idx += 1
|
|
489
|
+
out.append(idx)
|
|
490
|
+
return out
|
|
491
|
+
|
|
492
|
+
qx = _quantize(xv[:n], max(int(bins), 2))
|
|
493
|
+
qy = _quantize(yv[:n], max(int(bins), 2))
|
|
494
|
+
|
|
495
|
+
def _calc_te(src: List[int], dst: List[int]) -> float:
|
|
496
|
+
total = n - 1
|
|
497
|
+
c_xyz: Dict[Tuple[int, int, int], int] = {}
|
|
498
|
+
c_xy: Dict[Tuple[int, int], int] = {}
|
|
499
|
+
c_yz: Dict[Tuple[int, int], int] = {}
|
|
500
|
+
c_y: Dict[int, int] = {}
|
|
501
|
+
for t in range(1, n):
|
|
502
|
+
yt = dst[t]
|
|
503
|
+
yt1 = dst[t - 1]
|
|
504
|
+
xt1 = src[t - 1]
|
|
505
|
+
c_xyz[(yt, yt1, xt1)] = c_xyz.get((yt, yt1, xt1), 0) + 1
|
|
506
|
+
c_xy[(yt, yt1)] = c_xy.get((yt, yt1), 0) + 1
|
|
507
|
+
c_yz[(yt1, xt1)] = c_yz.get((yt1, xt1), 0) + 1
|
|
508
|
+
c_y[yt1] = c_y.get(yt1, 0) + 1
|
|
509
|
+
|
|
510
|
+
te_bits = 0.0
|
|
511
|
+
for (yt, yt1, xt1), cnt in c_xyz.items():
|
|
512
|
+
p_xyz = cnt / float(total)
|
|
513
|
+
p_yt_given_yt1_xt1 = cnt / float(c_yz[(yt1, xt1)])
|
|
514
|
+
p_yt_given_yt1 = c_xy[(yt, yt1)] / float(c_y[yt1])
|
|
515
|
+
if p_yt_given_yt1_xt1 > 0 and p_yt_given_yt1 > 0:
|
|
516
|
+
te_bits += p_xyz * math.log2(p_yt_given_yt1_xt1 / p_yt_given_yt1)
|
|
517
|
+
return max(te_bits, 0.0)
|
|
518
|
+
|
|
519
|
+
te_x_to_y = _calc_te(qx, qy)
|
|
520
|
+
te_y_to_x = _calc_te(qy, qx)
|
|
521
|
+
net_flow = te_x_to_y - te_y_to_x
|
|
522
|
+
|
|
523
|
+
if abs(net_flow) < 0.02:
|
|
524
|
+
direction = "Bidirectional / Weakly Coupled Feedback"
|
|
525
|
+
elif net_flow > 0:
|
|
526
|
+
direction = "X -> Y (X is the dominant nonlinear driver of Y)"
|
|
527
|
+
else:
|
|
528
|
+
direction = "Y -> X (Y is the dominant nonlinear driver of X)"
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
"n": n,
|
|
532
|
+
"bins": int(bins),
|
|
533
|
+
"transfer_entropy_x_to_y_bits": round(te_x_to_y, 5),
|
|
534
|
+
"transfer_entropy_y_to_x_bits": round(te_y_to_x, 5),
|
|
535
|
+
"net_information_flow_bits": round(net_flow, 5),
|
|
536
|
+
"dominant_causal_direction": direction,
|
|
537
|
+
"reference": "Runge et al. (Nature Communications, 2019; Nat. Rev. Earth Environ., 2023)",
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _solve_linear_system(a_mat: List[List[float]], b_vec: List[float]) -> List[float]:
|
|
542
|
+
"""Solve A * x = b via Gaussian elimination with partial pivoting (pure Python)."""
|
|
543
|
+
p = len(b_vec)
|
|
544
|
+
aug = [row[:] + [float(b_vec[i])] for i, row in enumerate(a_mat)]
|
|
545
|
+
for col in range(p):
|
|
546
|
+
pivot_row = max(range(col, p), key=lambda r: abs(aug[r][col]))
|
|
547
|
+
aug[col], aug[pivot_row] = aug[pivot_row], aug[col]
|
|
548
|
+
piv = aug[col][col]
|
|
549
|
+
if abs(piv) < 1e-12:
|
|
550
|
+
piv = 1e-12
|
|
551
|
+
for j in range(col, p + 1):
|
|
552
|
+
aug[col][j] /= piv
|
|
553
|
+
for r in range(p):
|
|
554
|
+
if r != col:
|
|
555
|
+
factor = aug[r][col]
|
|
556
|
+
for j in range(col, p + 1):
|
|
557
|
+
aug[r][j] -= factor * aug[col][j]
|
|
558
|
+
return [aug[i][p] for i in range(p)]
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def multivariate_data_mining_pipeline(
|
|
562
|
+
feature_matrix: Sequence[Sequence[float]],
|
|
563
|
+
feature_names: Optional[Sequence[str]] = None,
|
|
564
|
+
period_labels: Optional[Sequence[Any]] = None,
|
|
565
|
+
target_index: int = 0,
|
|
566
|
+
k_clusters: int = 3,
|
|
567
|
+
ridge_alphas: Sequence[float] = (0.01, 0.1, 1.0, 10.0),
|
|
568
|
+
) -> Dict[str, Any]:
|
|
569
|
+
"""
|
|
570
|
+
Execute an End-to-End Multivariate Ocean & Maritime Data Mining Pipeline:
|
|
571
|
+
1. Z-Score Standardization & Complete-Case Pearson Correlation Matrix (R = Z^T Z / (n-1))
|
|
572
|
+
2. Principal Component Analysis (PCA via Spectral Power-Iteration & Hotelling Deflation)
|
|
573
|
+
3. Deterministic K-Means++ Unsupervised Regime Clustering (Arthur & Vassilvitskii, 2007)
|
|
574
|
+
with Rousseeuw (1987) Silhouette Score validation
|
|
575
|
+
4. Expanding-Window Time-Series Cross-Validated Multivariate Lag-1 Ridge Regression
|
|
576
|
+
(Hoerl & Kennard, 1970; Bergmeir et al., 2018) vs. Naive Persistence Baseline
|
|
577
|
+
|
|
578
|
+
Parameters
|
|
579
|
+
----------
|
|
580
|
+
feature_matrix : Sequence[Sequence[float]]
|
|
581
|
+
2D matrix of shape (n_periods, n_features) with numeric observations.
|
|
582
|
+
feature_names : Sequence[str], optional
|
|
583
|
+
Variable names corresponding to columns of `feature_matrix`.
|
|
584
|
+
period_labels : Sequence[Any], optional
|
|
585
|
+
Time labels (e.g. years 1995..2025) for each row.
|
|
586
|
+
target_index : int, default 0
|
|
587
|
+
Column index of the target variable for supervised lag-1 Ridge forecasting.
|
|
588
|
+
k_clusters : int, default 3
|
|
589
|
+
Number of ocean-climate/maritime regimes for K-Means++ clustering.
|
|
590
|
+
ridge_alphas : Sequence[float], default (0.01, 0.1, 1.0, 10.0)
|
|
591
|
+
Candidate L2 regularization penalties evaluated via expanding-window CV.
|
|
592
|
+
"""
|
|
593
|
+
rows = [[float(v) for v in r] for r in feature_matrix if r is not None]
|
|
594
|
+
n = len(rows)
|
|
595
|
+
if n < 8 or not rows[0]:
|
|
596
|
+
raise ValueError("multivariate_data_mining_pipeline requires at least 8 complete rows and >=2 features.")
|
|
597
|
+
p = len(rows[0])
|
|
598
|
+
if p < 2:
|
|
599
|
+
raise ValueError("At least 2 features (columns) are required for multivariate data mining.")
|
|
600
|
+
|
|
601
|
+
names = list(feature_names) if (feature_names and len(feature_names) == p) else [f"V{j + 1}" for j in range(p)]
|
|
602
|
+
labels = [str(x) for x in period_labels] if (period_labels and len(period_labels) == n) else [str(i) for i in range(n)]
|
|
603
|
+
tgt_idx = max(0, min(p - 1, int(target_index)))
|
|
604
|
+
|
|
605
|
+
# 1. Column means, standard deviations, and Z-score matrix
|
|
606
|
+
means = [sum(rows[i][j] for i in range(n)) / n for j in range(p)]
|
|
607
|
+
stds = [
|
|
608
|
+
math.sqrt(max(sum((rows[i][j] - means[j]) ** 2 for i in range(n)) / max(n - 1, 1), 1e-12))
|
|
609
|
+
for j in range(p)
|
|
610
|
+
]
|
|
611
|
+
z_mat = [[(rows[i][j] - means[j]) / stds[j] for j in range(p)] for i in range(n)]
|
|
612
|
+
|
|
613
|
+
# Correlation matrix R (p x p)
|
|
614
|
+
corr_matrix: List[List[float]] = [[0.0] * p for _ in range(p)]
|
|
615
|
+
for j1 in range(p):
|
|
616
|
+
for j2 in range(p):
|
|
617
|
+
if j1 == j2:
|
|
618
|
+
corr_matrix[j1][j2] = 1.0
|
|
619
|
+
else:
|
|
620
|
+
r_val = sum(z_mat[i][j1] * z_mat[i][j2] for i in range(n)) / max(n - 1, 1)
|
|
621
|
+
corr_matrix[j1][j2] = round(max(-1.0, min(1.0, r_val)), 4)
|
|
622
|
+
|
|
623
|
+
# 2. Principal Component Analysis (Top 2 PCs via Power Iteration + Hotelling Deflation)
|
|
624
|
+
r_work = [[corr_matrix[j1][j2] for j2 in range(p)] for j1 in range(p)]
|
|
625
|
+
eigenvalues: List[float] = []
|
|
626
|
+
loadings: List[List[float]] = []
|
|
627
|
+
|
|
628
|
+
for comp_idx in range(min(2, p)):
|
|
629
|
+
vec = [1.0 / math.sqrt(p) if (j + comp_idx) % 2 == 0 else 0.5 / math.sqrt(p) for j in range(p)]
|
|
630
|
+
for _ in range(80):
|
|
631
|
+
nxt = [sum(r_work[j1][j2] * vec[j2] for j2 in range(p)) for j1 in range(p)]
|
|
632
|
+
norm = math.sqrt(max(sum(v * v for v in nxt), 1e-15))
|
|
633
|
+
vec = [v / norm for v in nxt]
|
|
634
|
+
# Sign convention: largest-magnitude loading positive
|
|
635
|
+
max_j = max(range(p), key=lambda j: abs(vec[j]))
|
|
636
|
+
if vec[max_j] < 0:
|
|
637
|
+
vec = [-v for v in vec]
|
|
638
|
+
eig_val = sum(vec[j1] * sum(r_work[j1][j2] * vec[j2] for j2 in range(p)) for j1 in range(p))
|
|
639
|
+
eig_val = max(eig_val, 0.0)
|
|
640
|
+
eigenvalues.append(eig_val)
|
|
641
|
+
loadings.append([round(v, 4) for v in vec])
|
|
642
|
+
# Hotelling deflation: R_next = R - lambda * v * v^T
|
|
643
|
+
for j1 in range(p):
|
|
644
|
+
for j2 in range(p):
|
|
645
|
+
r_work[j1][j2] -= eig_val * vec[j1] * vec[j2]
|
|
646
|
+
|
|
647
|
+
total_var = float(p)
|
|
648
|
+
evr = [round(ev / total_var, 4) for ev in eigenvalues]
|
|
649
|
+
pc_scores = [
|
|
650
|
+
{
|
|
651
|
+
"period": labels[i],
|
|
652
|
+
"pc1": round(sum(z_mat[i][j] * loadings[0][j] for j in range(p)), 4),
|
|
653
|
+
"pc2": round(sum(z_mat[i][j] * loadings[1][j] for j in range(p)), 4) if len(loadings) > 1 else 0.0,
|
|
654
|
+
}
|
|
655
|
+
for i in range(n)
|
|
656
|
+
]
|
|
657
|
+
|
|
658
|
+
# 3. Deterministic K-Means++ Regime Clustering & Silhouette Score
|
|
659
|
+
k = max(2, min(int(k_clusters), n // 2))
|
|
660
|
+
# Seed 1: earliest or lowest PC1 observation
|
|
661
|
+
first_idx = min(range(n), key=lambda i: pc_scores[i]["pc1"])
|
|
662
|
+
centers_z: List[List[float]] = [z_mat[first_idx][:]]
|
|
663
|
+
while len(centers_z) < k:
|
|
664
|
+
# Farthest-point deterministic K-Means++ seeding
|
|
665
|
+
best_i = max(
|
|
666
|
+
range(n),
|
|
667
|
+
key=lambda i: min(
|
|
668
|
+
sum((z_mat[i][j] - c[j]) ** 2 for j in range(p)) for c in centers_z
|
|
669
|
+
),
|
|
670
|
+
)
|
|
671
|
+
centers_z.append(z_mat[best_i][:])
|
|
672
|
+
|
|
673
|
+
assignments = [0] * n
|
|
674
|
+
for _ in range(50):
|
|
675
|
+
changed = False
|
|
676
|
+
for i in range(n):
|
|
677
|
+
best_c = min(
|
|
678
|
+
range(k),
|
|
679
|
+
key=lambda c_idx: sum((z_mat[i][j] - centers_z[c_idx][j]) ** 2 for j in range(p)),
|
|
680
|
+
)
|
|
681
|
+
if assignments[i] != best_c:
|
|
682
|
+
assignments[i] = best_c
|
|
683
|
+
changed = True
|
|
684
|
+
for c_idx in range(k):
|
|
685
|
+
members = [i for i in range(n) if assignments[i] == c_idx]
|
|
686
|
+
if members:
|
|
687
|
+
centers_z[c_idx] = [
|
|
688
|
+
sum(z_mat[i][j] for i in members) / len(members) for j in range(p)
|
|
689
|
+
]
|
|
690
|
+
if not changed:
|
|
691
|
+
break
|
|
692
|
+
|
|
693
|
+
# Order clusters chronologically by mean index so Regime 1 -> Regime K is intuitive
|
|
694
|
+
cluster_order = sorted(
|
|
695
|
+
range(k),
|
|
696
|
+
key=lambda c_idx: (
|
|
697
|
+
sum(i for i in range(n) if assignments[i] == c_idx)
|
|
698
|
+
/ max(1, sum(1 for i in range(n) if assignments[i] == c_idx))
|
|
699
|
+
),
|
|
700
|
+
)
|
|
701
|
+
remap = {old_c: new_c + 1 for new_c, old_c in enumerate(cluster_order)}
|
|
702
|
+
ordered_labels = [remap[c_idx] for c_idx in assignments]
|
|
703
|
+
|
|
704
|
+
wcss = sum(
|
|
705
|
+
sum((z_mat[i][j] - centers_z[assignments[i]][j]) ** 2 for j in range(p))
|
|
706
|
+
for i in range(n)
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
# Rousseeuw (1987) Silhouette Score
|
|
710
|
+
sil_vals: List[float] = []
|
|
711
|
+
for i in range(n):
|
|
712
|
+
ci = ordered_labels[i]
|
|
713
|
+
same = [m for m in range(n) if ordered_labels[m] == ci and m != i]
|
|
714
|
+
a_i = (
|
|
715
|
+
sum(math.sqrt(sum((z_mat[i][j] - z_mat[m][j]) ** 2 for j in range(p))) for m in same) / len(same)
|
|
716
|
+
if same
|
|
717
|
+
else 0.0
|
|
718
|
+
)
|
|
719
|
+
b_i = Infinity = float("inf")
|
|
720
|
+
for other_c in range(1, k + 1):
|
|
721
|
+
if other_c == ci:
|
|
722
|
+
continue
|
|
723
|
+
other_members = [m for m in range(n) if ordered_labels[m] == other_c]
|
|
724
|
+
if other_members:
|
|
725
|
+
dist_c = sum(
|
|
726
|
+
math.sqrt(sum((z_mat[i][j] - z_mat[m][j]) ** 2 for j in range(p)))
|
|
727
|
+
for m in other_members
|
|
728
|
+
) / len(other_members)
|
|
729
|
+
if dist_c < b_i:
|
|
730
|
+
b_i = dist_c
|
|
731
|
+
if math.isfinite(b_i) and max(a_i, b_i) > 0:
|
|
732
|
+
sil_vals.append((b_i - a_i) / max(a_i, b_i))
|
|
733
|
+
silhouette_score = sum(sil_vals) / len(sil_vals) if sil_vals else 0.0
|
|
734
|
+
|
|
735
|
+
cluster_summaries = []
|
|
736
|
+
for c_num in range(1, k + 1):
|
|
737
|
+
members = [i for i in range(n) if ordered_labels[i] == c_num]
|
|
738
|
+
if not members:
|
|
739
|
+
continue
|
|
740
|
+
cluster_summaries.append(
|
|
741
|
+
{
|
|
742
|
+
"regime_id": c_num,
|
|
743
|
+
"n_periods": len(members),
|
|
744
|
+
"period_span": f"{labels[members[0]]}–{labels[members[-1]]}",
|
|
745
|
+
"centroid_physical_units": {
|
|
746
|
+
names[j]: round(sum(rows[i][j] for i in members) / len(members), 4)
|
|
747
|
+
for j in range(p)
|
|
748
|
+
},
|
|
749
|
+
}
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
# 4. Supervised Lag-1 Multivariate Ridge Regression with Expanding-Window Time-Series CV
|
|
753
|
+
# Predict rows[t][tgt_idx] using z_mat[t-1][0..p-1]
|
|
754
|
+
x_lag = [z_mat[t - 1] for t in range(1, n)]
|
|
755
|
+
y_curr = [rows[t][tgt_idx] for t in range(1, n)]
|
|
756
|
+
n_pairs = len(y_curr)
|
|
757
|
+
split_idx = max(int(n_pairs * 0.7), p + 2)
|
|
758
|
+
split_idx = min(split_idx, n_pairs - 2)
|
|
759
|
+
|
|
760
|
+
def _fit_ridge(x_tr: List[List[float]], y_tr: List[float], alpha: float) -> Tuple[float, List[float]]:
|
|
761
|
+
m_tr = len(y_tr)
|
|
762
|
+
y_mean = sum(y_tr) / m_tr
|
|
763
|
+
x_means = [sum(x_tr[i][j] for i in range(m_tr)) / m_tr for j in range(p)]
|
|
764
|
+
xc = [[x_tr[i][j] - x_means[j] for j in range(p)] for i in range(m_tr)]
|
|
765
|
+
yc = [y_tr[i] - y_mean for i in range(m_tr)]
|
|
766
|
+
xtx = [
|
|
767
|
+
[
|
|
768
|
+
sum(xc[i][j1] * xc[i][j2] for i in range(m_tr)) + (alpha if j1 == j2 else 0.0)
|
|
769
|
+
for j2 in range(p)
|
|
770
|
+
]
|
|
771
|
+
for j1 in range(p)
|
|
772
|
+
]
|
|
773
|
+
xty = [sum(xc[i][j] * yc[i] for i in range(m_tr)) for j in range(p)]
|
|
774
|
+
betas = _solve_linear_system(xtx, xty)
|
|
775
|
+
intercept = y_mean - sum(betas[j] * x_means[j] for j in range(p))
|
|
776
|
+
return intercept, betas
|
|
777
|
+
|
|
778
|
+
best_alpha = float(ridge_alphas[0])
|
|
779
|
+
best_cv_rmse = float("inf")
|
|
780
|
+
cv_start = max(p + 2, split_idx // 2)
|
|
781
|
+
for alpha_cand in ridge_alphas:
|
|
782
|
+
sq_errs: List[float] = []
|
|
783
|
+
for t_eval in range(cv_start, split_idx):
|
|
784
|
+
b0, b_vec = _fit_ridge(x_lag[:t_eval], y_curr[:t_eval], float(alpha_cand))
|
|
785
|
+
pred = b0 + sum(b_vec[j] * x_lag[t_eval][j] for j in range(p))
|
|
786
|
+
sq_errs.append((y_curr[t_eval] - pred) ** 2)
|
|
787
|
+
cv_rmse = math.sqrt(sum(sq_errs) / max(len(sq_errs), 1)) if sq_errs else float("inf")
|
|
788
|
+
if cv_rmse < best_cv_rmse:
|
|
789
|
+
best_cv_rmse = cv_rmse
|
|
790
|
+
best_alpha = float(alpha_cand)
|
|
791
|
+
|
|
792
|
+
# Final out-of-sample test evaluation on [split_idx .. n_pairs-1]
|
|
793
|
+
b0_final, betas_final = _fit_ridge(x_lag[:split_idx], y_curr[:split_idx], best_alpha)
|
|
794
|
+
test_preds: List[Dict[str, Any]] = []
|
|
795
|
+
ridge_sq_err = 0.0
|
|
796
|
+
naive_sq_err = 0.0
|
|
797
|
+
ridge_abs_err = 0.0
|
|
798
|
+
for t_idx in range(split_idx, n_pairs):
|
|
799
|
+
obs_val = y_curr[t_idx]
|
|
800
|
+
pred_val = b0_final + sum(betas_final[j] * x_lag[t_idx][j] for j in range(p))
|
|
801
|
+
naive_val = y_curr[t_idx - 1]
|
|
802
|
+
ridge_sq_err += (obs_val - pred_val) ** 2
|
|
803
|
+
naive_sq_err += (obs_val - naive_val) ** 2
|
|
804
|
+
ridge_abs_err += abs(obs_val - pred_val)
|
|
805
|
+
test_preds.append(
|
|
806
|
+
{
|
|
807
|
+
"period": labels[t_idx + 1],
|
|
808
|
+
"observed": round(obs_val, 4),
|
|
809
|
+
"ridge_predicted": round(pred_val, 4),
|
|
810
|
+
"naive_baseline": round(naive_val, 4),
|
|
811
|
+
}
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
n_test = max(len(test_preds), 1)
|
|
815
|
+
test_rmse = math.sqrt(ridge_sq_err / n_test)
|
|
816
|
+
naive_rmse = math.sqrt(naive_sq_err / n_test)
|
|
817
|
+
test_mae = ridge_abs_err / n_test
|
|
818
|
+
skill_score = 1.0 - (test_rmse / max(naive_rmse, 1e-12))
|
|
819
|
+
|
|
820
|
+
return {
|
|
821
|
+
"n_periods": n,
|
|
822
|
+
"n_features": p,
|
|
823
|
+
"feature_names": names,
|
|
824
|
+
"target_variable": names[tgt_idx],
|
|
825
|
+
"correlation_matrix": {
|
|
826
|
+
names[j1]: {names[j2]: corr_matrix[j1][j2] for j2 in range(p)} for j1 in range(p)
|
|
827
|
+
},
|
|
828
|
+
"pca": {
|
|
829
|
+
"eigenvalues": [round(ev, 4) for ev in eigenvalues],
|
|
830
|
+
"explained_variance_ratio": evr,
|
|
831
|
+
"cumulative_variance_pc1_pc2_pct": round(sum(evr) * 100.0, 2),
|
|
832
|
+
"loadings_pc1": {names[j]: loadings[0][j] for j in range(p)},
|
|
833
|
+
"loadings_pc2": {names[j]: loadings[1][j] for j in range(p)} if len(loadings) > 1 else {},
|
|
834
|
+
"scores": pc_scores,
|
|
835
|
+
},
|
|
836
|
+
"kmeans_regimes": {
|
|
837
|
+
"k": k,
|
|
838
|
+
"wcss_inertia": round(wcss, 4),
|
|
839
|
+
"silhouette_score": round(silhouette_score, 4),
|
|
840
|
+
"regime_labels": ordered_labels,
|
|
841
|
+
"regimes": cluster_summaries,
|
|
842
|
+
},
|
|
843
|
+
"ridge_ml_forecast": {
|
|
844
|
+
"optimal_alpha_l2": best_alpha,
|
|
845
|
+
"cv_rmse": round(best_cv_rmse, 4),
|
|
846
|
+
"test_rmse": round(test_rmse, 4),
|
|
847
|
+
"naive_baseline_rmse": round(naive_rmse, 4),
|
|
848
|
+
"test_mae": round(test_mae, 4),
|
|
849
|
+
"forecast_skill_score_pct": round(skill_score * 100.0, 2),
|
|
850
|
+
"standardized_coefficients": {names[j]: round(betas_final[j], 5) for j in range(p)},
|
|
851
|
+
"intercept": round(b0_final, 5),
|
|
852
|
+
"test_predictions": test_preds,
|
|
853
|
+
},
|
|
854
|
+
"reference": (
|
|
855
|
+
"Jolliffe & Cadima (2016, PCA); Arthur & Vassilvitskii (2007, K-Means++); "
|
|
856
|
+
"Rousseeuw (1987, Silhouette); Hoerl & Kennard (1970, Ridge); Bergmeir et al. (2018, TS-CV)"
|
|
857
|
+
),
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def counter_r5_usage_econometrics(
|
|
862
|
+
pageviews_by_module: Union[Dict[str, float], Sequence[float]],
|
|
863
|
+
unique_visitors_by_module: Optional[Union[Dict[str, float], Sequence[float]]] = None,
|
|
864
|
+
item_requests_by_module: Optional[Union[Dict[str, float], Sequence[float]]] = None,
|
|
865
|
+
dau: int = 0,
|
|
866
|
+
wau: int = 0,
|
|
867
|
+
mau: int = 0,
|
|
868
|
+
authenticated_sessions: int = 0,
|
|
869
|
+
total_sessions: int = 0,
|
|
870
|
+
) -> Dict[str, Any]:
|
|
871
|
+
"""
|
|
872
|
+
Project COUNTER Release 5.1 and Make Data Count (Fenner et al., 2019;
|
|
873
|
+
Cousijn et al., 2019) usage metrics plus information-theoretic concentration
|
|
874
|
+
measures for a scientific portal.
|
|
875
|
+
|
|
876
|
+
Computes:
|
|
877
|
+
1. `Total_Item_Investigations` & `Unique_Item_Investigations` (COUNTER R5.1)
|
|
878
|
+
2. `Total_Item_Requests` & `Unique_Item_Requests` (Dataset/API/SDK/PDF retrievals)
|
|
879
|
+
3. Conversion Efficiency Ratio: CER = Unique_Item_Requests / Unique_Item_Investigations
|
|
880
|
+
4. Shannon Attention Entropy: H' = -sum(p_i * log2(p_i)) [bits] and Pielou Evenness J' = H' / log2(S)
|
|
881
|
+
5. Gini Traffic Concentration Coefficient (G in [0, 1]) & Herfindahl-Hirschman Index (HHI)
|
|
882
|
+
6. DAU/MAU Stickiness Ratio (%) & Institutional Authenticated Session Share (%)
|
|
883
|
+
|
|
884
|
+
Metrics whose inputs are not supplied are returned as ``None``; no values are
|
|
885
|
+
imputed from assumed ratios.
|
|
886
|
+
"""
|
|
887
|
+
if isinstance(pageviews_by_module, dict):
|
|
888
|
+
mod_names = list(pageviews_by_module.keys())
|
|
889
|
+
inv_vals = [max(0.0, float(pageviews_by_module[k])) for k in mod_names]
|
|
890
|
+
else:
|
|
891
|
+
inv_vals = [max(0.0, float(x)) for x in pageviews_by_module]
|
|
892
|
+
mod_names = [f"module_{i+1}" for i in range(len(inv_vals))]
|
|
893
|
+
|
|
894
|
+
n_mods = len(inv_vals)
|
|
895
|
+
if n_mods == 0 or sum(inv_vals) <= 0:
|
|
896
|
+
raise ValueError("pageviews_by_module must contain at least one positive module count.")
|
|
897
|
+
|
|
898
|
+
if unique_visitors_by_module is None:
|
|
899
|
+
uniq_inv_vals = None
|
|
900
|
+
elif isinstance(unique_visitors_by_module, dict):
|
|
901
|
+
uniq_inv_vals = [max(0.0, float(unique_visitors_by_module.get(k, 0.0))) for k in mod_names]
|
|
902
|
+
else:
|
|
903
|
+
uniq_inv_vals = [max(0.0, float(x)) for x in unique_visitors_by_module]
|
|
904
|
+
|
|
905
|
+
if item_requests_by_module is None:
|
|
906
|
+
req_vals = None
|
|
907
|
+
elif isinstance(item_requests_by_module, dict):
|
|
908
|
+
req_vals = [max(0.0, float(item_requests_by_module.get(k, 0.0))) for k in mod_names]
|
|
909
|
+
else:
|
|
910
|
+
req_vals = [max(0.0, float(x)) for x in item_requests_by_module]
|
|
911
|
+
|
|
912
|
+
total_investigations = float(sum(inv_vals))
|
|
913
|
+
unique_investigations = float(sum(uniq_inv_vals)) if uniq_inv_vals is not None else None
|
|
914
|
+
total_requests = float(sum(req_vals)) if req_vals is not None else None
|
|
915
|
+
unique_requests = (
|
|
916
|
+
float(sum(min(r, u) for r, u in zip(req_vals, uniq_inv_vals)))
|
|
917
|
+
if req_vals is not None and uniq_inv_vals is not None
|
|
918
|
+
else None
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
cer_pct = (
|
|
922
|
+
(unique_requests / unique_investigations) * 100.0
|
|
923
|
+
if unique_requests is not None and unique_investigations
|
|
924
|
+
else None
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
# Shannon Attention Entropy H' (bits), Pielou Evenness J', and HHI
|
|
928
|
+
shannon_bits = 0.0
|
|
929
|
+
hhi = 0.0
|
|
930
|
+
shares: Dict[str, float] = {}
|
|
931
|
+
for name, val in zip(mod_names, inv_vals):
|
|
932
|
+
p = val / total_investigations
|
|
933
|
+
shares[name] = round(p * 100.0, 2)
|
|
934
|
+
if p > 0.0:
|
|
935
|
+
shannon_bits -= p * math.log2(p)
|
|
936
|
+
hhi += p * p
|
|
937
|
+
|
|
938
|
+
pielou_j = shannon_bits / math.log2(n_mods) if n_mods > 1 else 1.0
|
|
939
|
+
|
|
940
|
+
# Gini Traffic Concentration
|
|
941
|
+
sorted_vals = sorted(inv_vals)
|
|
942
|
+
if n_mods > 1 and total_investigations > 0:
|
|
943
|
+
weighted = sum((2.0 * (i + 1) - n_mods - 1.0) * v for i, v in enumerate(sorted_vals))
|
|
944
|
+
gini = max(0.0, min(1.0, weighted / (n_mods * total_investigations)))
|
|
945
|
+
else:
|
|
946
|
+
gini = 0.0
|
|
947
|
+
|
|
948
|
+
dau_val = int(dau) if dau > 0 else None
|
|
949
|
+
wau_val = int(wau) if wau > 0 else None
|
|
950
|
+
mau_val = int(mau) if mau > 0 else None
|
|
951
|
+
stickiness_dau_mau_pct = (dau_val / mau_val) * 100.0 if dau_val is not None and mau_val else None
|
|
952
|
+
wau_mau_pct = (wau_val / mau_val) * 100.0 if wau_val is not None and mau_val else None
|
|
953
|
+
auth_share_pct = (
|
|
954
|
+
(int(authenticated_sessions) / int(total_sessions)) * 100.0
|
|
955
|
+
if total_sessions > 0 and authenticated_sessions >= 0
|
|
956
|
+
else None
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
def _r(v: Optional[float], nd: int) -> Optional[float]:
|
|
960
|
+
return round(v, nd) if v is not None else None
|
|
961
|
+
|
|
962
|
+
def _i(v: Optional[float]) -> Optional[int]:
|
|
963
|
+
return int(round(v)) if v is not None else None
|
|
964
|
+
|
|
965
|
+
return {
|
|
966
|
+
"n_modules": n_mods,
|
|
967
|
+
"counter_r51_metrics": {
|
|
968
|
+
"total_item_investigations": int(round(total_investigations)),
|
|
969
|
+
"unique_item_investigations": _i(unique_investigations),
|
|
970
|
+
"total_item_requests": _i(total_requests),
|
|
971
|
+
"unique_item_requests": _i(unique_requests),
|
|
972
|
+
"conversion_efficiency_ratio_pct": _r(cer_pct, 2),
|
|
973
|
+
},
|
|
974
|
+
"information_theory_concentration": {
|
|
975
|
+
"shannon_attention_entropy_bits": round(shannon_bits, 4),
|
|
976
|
+
"pielou_evenness_index": round(pielou_j, 4),
|
|
977
|
+
"gini_concentration_index": round(gini, 4),
|
|
978
|
+
"herfindahl_hirschman_index": round(hhi, 4),
|
|
979
|
+
"module_share_pct": shares,
|
|
980
|
+
},
|
|
981
|
+
"engagement_and_auth": {
|
|
982
|
+
"dau": dau_val,
|
|
983
|
+
"wau": wau_val,
|
|
984
|
+
"mau": mau_val,
|
|
985
|
+
"stickiness_dau_mau_pct": _r(stickiness_dau_mau_pct, 2),
|
|
986
|
+
"wau_mau_ratio_pct": _r(wau_mau_pct, 2),
|
|
987
|
+
"authenticated_session_share_pct": _r(auth_share_pct, 2),
|
|
988
|
+
"privacy_architecture": "Zero-Cookie SHA-256 Daily Rotating Hash (KVKK & GDPR Art. 6(1)(f))",
|
|
989
|
+
},
|
|
990
|
+
"reference": (
|
|
991
|
+
"Project COUNTER Code of Practice Release 5.1; "
|
|
992
|
+
"Fenner et al. (2019, Scientific Data); "
|
|
993
|
+
"Cousijn et al. (2019, Data Science Journal)"
|
|
994
|
+
),
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def audit_numeric_grounding(
|
|
999
|
+
answer: str,
|
|
1000
|
+
reference_values: Sequence[float],
|
|
1001
|
+
question: str = "",
|
|
1002
|
+
ignore_years: bool = True,
|
|
1003
|
+
ignore_small_integers: int = 12,
|
|
1004
|
+
) -> Dict[str, Any]:
|
|
1005
|
+
"""
|
|
1006
|
+
Audit whether the numbers in a generated (e.g. LLM) answer are grounded in a set
|
|
1007
|
+
of reference values, such as the observations returned by ``get_series`` or the
|
|
1008
|
+
statistics returned by ``mann_kendall_sen``.
|
|
1009
|
+
|
|
1010
|
+
A number written with ``d`` decimals is considered grounded if some reference
|
|
1011
|
+
value ``r`` satisfies ``|abs(r) - abs(x)| <= 0.5 * 10**(-d)``, i.e. the answer is a
|
|
1012
|
+
correct rounding of a reference value. Both decimal comma (Turkish) and decimal
|
|
1013
|
+
point (English) notation and thousands separators are handled; ambiguous tokens
|
|
1014
|
+
such as ``2.350`` are accepted under either reading. Integers 1800-2100 (years),
|
|
1015
|
+
small integers (list numbering, months) and numbers present in the question are
|
|
1016
|
+
skipped.
|
|
1017
|
+
|
|
1018
|
+
This mirrors the server-side check used by the GOMD Data Assistant and is a
|
|
1019
|
+
deterministic attribution test in the spirit of attributable-to-identified-sources
|
|
1020
|
+
evaluation (Rashkin et al., 2023, Computational Linguistics); it does not judge
|
|
1021
|
+
whether a correct number is used in the right context.
|
|
1022
|
+
"""
|
|
1023
|
+
import re as _re
|
|
1024
|
+
|
|
1025
|
+
def _tokens(text: str) -> List[Tuple[str, List[Tuple[float, int]]]]:
|
|
1026
|
+
clean = _re.sub(r"\[[^\]]{1,120}\]", " ", text or "")
|
|
1027
|
+
clean = _re.sub(r"\b\d{4}-\d{2}(-\d{2})?\b", " ", clean)
|
|
1028
|
+
pat = r"(?<![\w])-?\d{1,3}(?:[.,]\d{3})+(?:[.,]\d+)?(?!\d)|(?<![\w])-?\d+(?:[.,]\d+)?(?!\d)"
|
|
1029
|
+
out: List[Tuple[str, List[Tuple[float, int]]]] = []
|
|
1030
|
+
for tok in _re.findall(pat, clean):
|
|
1031
|
+
t = tok.lstrip("-")
|
|
1032
|
+
cands: List[str] = []
|
|
1033
|
+
if "." in t and "," in t:
|
|
1034
|
+
cands.append(t.replace(".", "").replace(",", ".") if t.rfind(",") > t.rfind(".") else t.replace(",", ""))
|
|
1035
|
+
elif "," in t:
|
|
1036
|
+
cands.append(t.replace(",", "."))
|
|
1037
|
+
if _re.fullmatch(r"\d{1,3}(,\d{3})+", t):
|
|
1038
|
+
cands.append(t.replace(",", ""))
|
|
1039
|
+
elif "." in t:
|
|
1040
|
+
cands.append(t)
|
|
1041
|
+
if _re.fullmatch(r"\d{1,3}(\.\d{3})+", t):
|
|
1042
|
+
cands.append(t.replace(".", ""))
|
|
1043
|
+
else:
|
|
1044
|
+
cands.append(t)
|
|
1045
|
+
vals = []
|
|
1046
|
+
for c in cands:
|
|
1047
|
+
try:
|
|
1048
|
+
dec = len(c.split(".", 1)[1]) if "." in c else 0
|
|
1049
|
+
vals.append((float(c), dec))
|
|
1050
|
+
except ValueError:
|
|
1051
|
+
pass
|
|
1052
|
+
if vals:
|
|
1053
|
+
out.append((tok, vals))
|
|
1054
|
+
return out
|
|
1055
|
+
|
|
1056
|
+
pool = [abs(float(v)) for v in reference_values if v is not None and not (isinstance(v, float) and math.isnan(v))]
|
|
1057
|
+
q_nums = {abs(v) for _, vals in _tokens(question) for v, _ in vals}
|
|
1058
|
+
grounded: List[str] = []
|
|
1059
|
+
unverified: List[str] = []
|
|
1060
|
+
for tok, vals in _tokens(answer):
|
|
1061
|
+
ok = False
|
|
1062
|
+
for v, dec in vals:
|
|
1063
|
+
av = abs(v)
|
|
1064
|
+
if dec == 0 and ((ignore_years and 1800 <= av <= 2100) or av <= ignore_small_integers):
|
|
1065
|
+
ok = True
|
|
1066
|
+
break
|
|
1067
|
+
if any(abs(av - q) < 1e-9 for q in q_nums):
|
|
1068
|
+
ok = True
|
|
1069
|
+
break
|
|
1070
|
+
tol = 0.5 * 10 ** (-dec) + 1e-9
|
|
1071
|
+
if any(abs(p - av) <= tol for p in pool):
|
|
1072
|
+
ok = True
|
|
1073
|
+
break
|
|
1074
|
+
(grounded if ok else unverified).append(tok)
|
|
1075
|
+
checked = len(grounded) + len(unverified)
|
|
1076
|
+
return {
|
|
1077
|
+
"numbers_checked": checked,
|
|
1078
|
+
"grounded": grounded,
|
|
1079
|
+
"unverified": unverified,
|
|
1080
|
+
"grounding_rate": (len(grounded) / checked) if checked else None,
|
|
1081
|
+
"method": "rounding-tolerance match against reference values; years, small integers and question numbers skipped",
|
|
1082
|
+
"reference": "Rashkin et al. (2023, Computational Linguistics 49(4)); Lewis et al. (2020, NeurIPS)",
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
|