tsagentkit 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. tsagentkit/__init__.py +126 -0
  2. tsagentkit/anomaly/__init__.py +130 -0
  3. tsagentkit/backtest/__init__.py +48 -0
  4. tsagentkit/backtest/engine.py +788 -0
  5. tsagentkit/backtest/metrics.py +244 -0
  6. tsagentkit/backtest/report.py +342 -0
  7. tsagentkit/calibration/__init__.py +136 -0
  8. tsagentkit/contracts/__init__.py +133 -0
  9. tsagentkit/contracts/errors.py +275 -0
  10. tsagentkit/contracts/results.py +418 -0
  11. tsagentkit/contracts/schema.py +44 -0
  12. tsagentkit/contracts/task_spec.py +300 -0
  13. tsagentkit/covariates/__init__.py +340 -0
  14. tsagentkit/eval/__init__.py +285 -0
  15. tsagentkit/features/__init__.py +20 -0
  16. tsagentkit/features/covariates.py +328 -0
  17. tsagentkit/features/extra/__init__.py +5 -0
  18. tsagentkit/features/extra/native.py +179 -0
  19. tsagentkit/features/factory.py +187 -0
  20. tsagentkit/features/matrix.py +159 -0
  21. tsagentkit/features/tsfeatures_adapter.py +115 -0
  22. tsagentkit/features/versioning.py +203 -0
  23. tsagentkit/hierarchy/__init__.py +39 -0
  24. tsagentkit/hierarchy/aggregation.py +62 -0
  25. tsagentkit/hierarchy/evaluator.py +400 -0
  26. tsagentkit/hierarchy/reconciliation.py +232 -0
  27. tsagentkit/hierarchy/structure.py +453 -0
  28. tsagentkit/models/__init__.py +182 -0
  29. tsagentkit/models/adapters/__init__.py +83 -0
  30. tsagentkit/models/adapters/base.py +321 -0
  31. tsagentkit/models/adapters/chronos.py +387 -0
  32. tsagentkit/models/adapters/moirai.py +256 -0
  33. tsagentkit/models/adapters/registry.py +171 -0
  34. tsagentkit/models/adapters/timesfm.py +440 -0
  35. tsagentkit/models/baselines.py +207 -0
  36. tsagentkit/models/sktime.py +307 -0
  37. tsagentkit/monitoring/__init__.py +51 -0
  38. tsagentkit/monitoring/alerts.py +302 -0
  39. tsagentkit/monitoring/coverage.py +203 -0
  40. tsagentkit/monitoring/drift.py +330 -0
  41. tsagentkit/monitoring/report.py +214 -0
  42. tsagentkit/monitoring/stability.py +275 -0
  43. tsagentkit/monitoring/triggers.py +423 -0
  44. tsagentkit/qa/__init__.py +347 -0
  45. tsagentkit/router/__init__.py +37 -0
  46. tsagentkit/router/bucketing.py +489 -0
  47. tsagentkit/router/fallback.py +132 -0
  48. tsagentkit/router/plan.py +23 -0
  49. tsagentkit/router/router.py +271 -0
  50. tsagentkit/series/__init__.py +26 -0
  51. tsagentkit/series/alignment.py +206 -0
  52. tsagentkit/series/dataset.py +449 -0
  53. tsagentkit/series/sparsity.py +261 -0
  54. tsagentkit/series/validation.py +393 -0
  55. tsagentkit/serving/__init__.py +39 -0
  56. tsagentkit/serving/orchestration.py +943 -0
  57. tsagentkit/serving/packaging.py +73 -0
  58. tsagentkit/serving/provenance.py +317 -0
  59. tsagentkit/serving/tsfm_cache.py +214 -0
  60. tsagentkit/skill/README.md +135 -0
  61. tsagentkit/skill/__init__.py +8 -0
  62. tsagentkit/skill/recipes.md +429 -0
  63. tsagentkit/skill/tool_map.md +21 -0
  64. tsagentkit/time/__init__.py +134 -0
  65. tsagentkit/utils/__init__.py +20 -0
  66. tsagentkit/utils/quantiles.py +83 -0
  67. tsagentkit/utils/signature.py +47 -0
  68. tsagentkit/utils/temporal.py +41 -0
  69. tsagentkit-1.0.2.dist-info/METADATA +371 -0
  70. tsagentkit-1.0.2.dist-info/RECORD +72 -0
  71. tsagentkit-1.0.2.dist-info/WHEEL +4 -0
  72. tsagentkit-1.0.2.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,347 @@
1
+ """QA checks and PIT-safe repairs for tsagentkit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Literal
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ from tsagentkit.contracts import (
12
+ ECovariateIncompleteKnown,
13
+ ECovariateLeakage,
14
+ ECovariateStaticInvalid,
15
+ EQARepairPeeksFuture,
16
+ RepairReport,
17
+ TaskSpec,
18
+ )
19
+ from tsagentkit.covariates import align_covariates
20
+ from tsagentkit.time import normalize_pandas_freq
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class QAReport:
25
+ """Quality assurance report."""
26
+
27
+ issues: list[dict[str, Any]] = field(default_factory=list)
28
+ repairs: list[RepairReport] = field(default_factory=list)
29
+ leakage_detected: bool = False
30
+
31
+ @property
32
+ def valid(self) -> bool:
33
+ """Whether QA passed (no critical issues)."""
34
+ return not self.has_critical_issues()
35
+
36
+ def has_critical_issues(self) -> bool:
37
+ return any(issue.get("severity") == "critical" for issue in self.issues)
38
+
39
+ def to_dict(self) -> dict[str, Any]:
40
+ repairs_list: list[dict[str, Any]] = []
41
+ for r in self.repairs:
42
+ if hasattr(r, "to_dict"):
43
+ repairs_list.append(r.to_dict())
44
+ else:
45
+ repairs_list.append(r)
46
+ return {
47
+ "issues": self.issues,
48
+ "repairs": repairs_list,
49
+ "leakage_detected": self.leakage_detected,
50
+ }
51
+
52
+
53
+ def run_qa(
54
+ data: pd.DataFrame,
55
+ task_spec: TaskSpec,
56
+ mode: Literal["quick", "standard", "strict"] = "standard",
57
+ zero_threshold: float = 0.3,
58
+ outlier_z: float = 3.0,
59
+ apply_repairs: bool = False,
60
+ repair_strategy: dict[str, Any] | None = None,
61
+ skip_covariate_checks: bool = False,
62
+ ) -> QAReport:
63
+ """Run QA checks for missing values, gaps, outliers, and leakage."""
64
+ repair_strategy = repair_strategy or {}
65
+ missing_method = repair_strategy.get("missing_method", "ffill")
66
+ winsorize_cfg = repair_strategy.get("winsorize", {"window": 30, "lower_q": 0.01, "upper_q": 0.99})
67
+ median_cfg = repair_strategy.get("median_filter", {"window": 7})
68
+ outlier_z = float(repair_strategy.get("outlier_z", outlier_z))
69
+
70
+ issues: list[dict[str, Any]] = []
71
+ repairs: list[dict[str, Any]] = []
72
+ leakage_detected = False
73
+
74
+ contract = task_spec.panel_contract
75
+ uid_col = contract.unique_id_col
76
+ ds_col = contract.ds_col
77
+ y_col = contract.y_col
78
+
79
+ df = data
80
+ if not pd.api.types.is_datetime64_any_dtype(df[ds_col]):
81
+ df[ds_col] = pd.to_datetime(df[ds_col])
82
+
83
+ # Per-series last observed
84
+ last_observed = (
85
+ df[df[y_col].notna()]
86
+ .groupby(uid_col)[ds_col]
87
+ .max()
88
+ .to_dict()
89
+ )
90
+
91
+ # Missing values in observed history only
92
+ missing_mask = df[y_col].isna()
93
+ if last_observed:
94
+ mask = df[uid_col].map(last_observed)
95
+ missing_mask = missing_mask & (df[ds_col] <= mask)
96
+ missing_count = int(missing_mask.sum())
97
+ if missing_count > 0:
98
+ issues.append(
99
+ {
100
+ "type": "missing_values",
101
+ "column": y_col,
102
+ "count": missing_count,
103
+ "severity": "critical" if mode == "strict" else "warning",
104
+ }
105
+ )
106
+
107
+ # Gaps per series
108
+ gap_count = 0
109
+ gap_ratio = 0.0
110
+ for uid in df[uid_col].unique():
111
+ series = df[df[uid_col] == uid].sort_values(ds_col)
112
+ if series.empty:
113
+ continue
114
+ full_range = pd.date_range(
115
+ start=series[ds_col].min(),
116
+ end=series[ds_col].max(),
117
+ freq=normalize_pandas_freq(task_spec.freq),
118
+ )
119
+ missing = len(full_range) - len(series)
120
+ if missing > 0:
121
+ gap_count += missing
122
+ gap_ratio += missing / max(len(full_range), 1)
123
+
124
+ if gap_count > 0:
125
+ issues.append(
126
+ {
127
+ "type": "gaps",
128
+ "count": gap_count,
129
+ "ratio": gap_ratio / max(df[uid_col].nunique(), 1),
130
+ "severity": "warning",
131
+ }
132
+ )
133
+
134
+ # Zero density
135
+ zero_ratio = float(np.mean(df[y_col] == 0)) if len(df) > 0 else 0.0
136
+ if zero_ratio > zero_threshold:
137
+ issues.append(
138
+ {
139
+ "type": "zero_density",
140
+ "ratio": zero_ratio,
141
+ "threshold": zero_threshold,
142
+ "severity": "warning",
143
+ }
144
+ )
145
+
146
+ # Outliers (z-score per series)
147
+ outlier_count = 0
148
+ for uid in df[uid_col].unique():
149
+ series = df[df[uid_col] == uid][y_col].astype(float)
150
+ if series.empty:
151
+ continue
152
+ mean = series.mean()
153
+ std = series.std()
154
+ if std == 0 or np.isnan(std):
155
+ continue
156
+ z_scores = (series - mean) / std
157
+ outlier_count += int((np.abs(z_scores) > outlier_z).sum())
158
+
159
+ if outlier_count > 0:
160
+ issues.append(
161
+ {
162
+ "type": "outliers",
163
+ "count": outlier_count,
164
+ "z_threshold": outlier_z,
165
+ "severity": "warning",
166
+ }
167
+ )
168
+
169
+ # Monotonicity check per series
170
+ monotonic_violations = 0
171
+ for uid in df[uid_col].unique():
172
+ series = df[df[uid_col] == uid]
173
+ if not series[ds_col].is_monotonic_increasing:
174
+ monotonic_violations += 1
175
+ if monotonic_violations > 0:
176
+ issues.append(
177
+ {
178
+ "type": "ds_not_monotonic",
179
+ "count": monotonic_violations,
180
+ "severity": "critical" if mode == "strict" else "warning",
181
+ }
182
+ )
183
+
184
+ # Minimum history length check
185
+ min_history = task_spec.backtest.min_train_size
186
+ if min_history:
187
+ lengths = df[df[y_col].notna()].groupby(uid_col).size()
188
+ short = lengths[lengths < min_history]
189
+ if not short.empty:
190
+ issues.append(
191
+ {
192
+ "type": "min_history",
193
+ "count": int(short.shape[0]),
194
+ "min_train_size": min_history,
195
+ "severity": "critical" if mode == "strict" else "warning",
196
+ }
197
+ )
198
+
199
+ # Covariate guardrails
200
+ if not skip_covariate_checks:
201
+ try:
202
+ align_covariates(df, task_spec)
203
+ except (ECovariateLeakage, ECovariateIncompleteKnown, ECovariateStaticInvalid) as exc:
204
+ leakage_detected = isinstance(exc, ECovariateLeakage)
205
+ issues.append(
206
+ {
207
+ "type": "covariate_guardrail",
208
+ "error": str(exc),
209
+ "severity": "critical",
210
+ }
211
+ )
212
+ raise
213
+
214
+ repairs: list[RepairReport] = []
215
+ if apply_repairs:
216
+ repairs = _apply_repairs(
217
+ df,
218
+ uid_col=uid_col,
219
+ ds_col=ds_col,
220
+ y_col=y_col,
221
+ last_observed=last_observed,
222
+ missing_method=missing_method,
223
+ winsorize_cfg=winsorize_cfg,
224
+ median_cfg=median_cfg,
225
+ strict=(mode == "strict"),
226
+ )
227
+
228
+ return QAReport(
229
+ issues=issues,
230
+ repairs=repairs,
231
+ leakage_detected=leakage_detected,
232
+ )
233
+
234
+
235
+ def _apply_repairs(
236
+ data: pd.DataFrame,
237
+ uid_col: str,
238
+ ds_col: str,
239
+ y_col: str,
240
+ last_observed: dict[str, Any],
241
+ missing_method: str,
242
+ winsorize_cfg: dict[str, Any],
243
+ median_cfg: dict[str, Any],
244
+ strict: bool,
245
+ ) -> list[RepairReport]:
246
+ if y_col in data.columns:
247
+ data[y_col] = data[y_col].astype(float)
248
+
249
+ repairs: list[RepairReport] = []
250
+ missing_filled = 0
251
+ outliers_clipped = 0
252
+ median_applied = 0
253
+
254
+ for uid in data[uid_col].unique():
255
+ series_idx = data[uid_col] == uid
256
+ series = data.loc[series_idx].sort_values(ds_col).copy()
257
+ if series.empty or not series[y_col].notna().any():
258
+ continue
259
+
260
+ last_obs = last_observed.get(uid)
261
+ observed_mask = series[ds_col] <= last_obs if last_obs is not None else pd.Series(False, index=series.index)
262
+
263
+ if missing_method in {"ffill", "bfill"}:
264
+ if missing_method == "bfill" and strict:
265
+ raise EQARepairPeeksFuture(
266
+ "bfill is non-causal in strict mode.",
267
+ context={"missing_method": missing_method},
268
+ )
269
+ missing_mask = series[y_col].isna() & observed_mask
270
+ if missing_mask.any():
271
+ if missing_method == "ffill":
272
+ filled = series.loc[observed_mask, y_col].ffill()
273
+ else:
274
+ filled = series.loc[observed_mask, y_col].bfill()
275
+ series.loc[observed_mask, y_col] = filled
276
+ missing_filled += int(missing_mask.sum())
277
+
278
+ # Winsorize using rolling historical quantiles (left-closed window)
279
+ if winsorize_cfg:
280
+ window = int(winsorize_cfg.get("window", 30))
281
+ lower_q = float(winsorize_cfg.get("lower_q", 0.01))
282
+ upper_q = float(winsorize_cfg.get("upper_q", 0.99))
283
+ observed_values = series.loc[observed_mask, y_col].astype(float)
284
+ shifted = observed_values.shift(1)
285
+ lower = shifted.rolling(window, min_periods=1).quantile(lower_q)
286
+ upper = shifted.rolling(window, min_periods=1).quantile(upper_q)
287
+ clipped = observed_values.copy()
288
+ clipped = clipped.where(lower.isna() | (clipped >= lower), lower)
289
+ clipped = clipped.where(upper.isna() | (clipped <= upper), upper)
290
+ outliers_clipped += int((clipped != observed_values).sum())
291
+ series.loc[observed_mask, y_col] = clipped
292
+
293
+ # Median filter using historical window (left-closed)
294
+ if median_cfg:
295
+ window = int(median_cfg.get("window", 7))
296
+ observed_values = series.loc[observed_mask, y_col].astype(float)
297
+ shifted = observed_values.shift(1)
298
+ median = shifted.rolling(window, min_periods=1).median()
299
+ filled = observed_values.where(median.isna(), median)
300
+ median_applied += int((filled != observed_values).sum())
301
+ series.loc[observed_mask, y_col] = filled
302
+
303
+ data.loc[series.index, y_col] = series[y_col].values
304
+
305
+ if missing_filled > 0:
306
+ repairs.append(
307
+ RepairReport(
308
+ repair_type="missing_values",
309
+ column=y_col,
310
+ count=missing_filled,
311
+ method=missing_method,
312
+ scope="observed_history",
313
+ pit_safe=missing_method != "bfill",
314
+ validation_passed=True,
315
+ )
316
+ )
317
+
318
+ if outliers_clipped > 0:
319
+ repairs.append(
320
+ RepairReport(
321
+ repair_type="winsorize",
322
+ column=y_col,
323
+ count=outliers_clipped,
324
+ method="rolling_quantiles",
325
+ scope="observed_history",
326
+ pit_safe=True,
327
+ validation_passed=True,
328
+ )
329
+ )
330
+
331
+ if median_applied > 0:
332
+ repairs.append(
333
+ RepairReport(
334
+ repair_type="median_filter",
335
+ column=y_col,
336
+ count=median_applied,
337
+ method="rolling_median",
338
+ scope="observed_history",
339
+ pit_safe=True,
340
+ validation_passed=True,
341
+ )
342
+ )
343
+
344
+ return repairs
345
+
346
+
347
+ __all__ = ["QAReport", "run_qa"]
@@ -0,0 +1,37 @@
1
+ """Router module for tsagentkit.
2
+
3
+ Provides model selection and fallback strategies.
4
+ """
5
+
6
+ from tsagentkit.contracts import RouteDecision
7
+
8
+ from .bucketing import (
9
+ BucketConfig,
10
+ BucketProfile,
11
+ BucketStatistics,
12
+ DataBucketer,
13
+ SeriesBucket,
14
+ )
15
+ from .fallback import FallbackLadder, execute_with_fallback
16
+ from .plan import PlanSpec, compute_plan_signature, get_candidate_models
17
+ from .router import get_model_for_series, make_plan
18
+
19
+ __all__ = [
20
+ # Plan
21
+ "PlanSpec",
22
+ "compute_plan_signature",
23
+ "get_candidate_models",
24
+ # Router
25
+ "make_plan",
26
+ "get_model_for_series",
27
+ "RouteDecision",
28
+ # Fallback
29
+ "FallbackLadder",
30
+ "execute_with_fallback",
31
+ # Bucketing (v0.2)
32
+ "DataBucketer",
33
+ "BucketConfig",
34
+ "BucketProfile",
35
+ "BucketStatistics",
36
+ "SeriesBucket",
37
+ ]