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/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
+