baxter-ts 0.1.1__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.
baxter_ts/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """
2
+ baxter-ts: AutoML time series library with BAX behavioural explanation.
3
+
4
+ Usage:
5
+ from baxter_ts import BAXModel
6
+
7
+ model = BAXModel()
8
+ model.fit(df, target_col="sales", date_col="date")
9
+ model.predict(steps=30)
10
+ model.explain()
11
+ model.anomalies()
12
+ model.visualize()
13
+ model.report("my_report")
14
+ """
15
+
16
+ from baxter_ts.core import BAXModel
17
+
18
+ __version__ = "0.1.1"
19
+ __all__ = ["BAXModel"]
@@ -0,0 +1,3 @@
1
+ from baxter_ts.anomaly.detector import AnomalyDetector
2
+
3
+ __all__ = ["AnomalyDetector"]
@@ -0,0 +1,118 @@
1
+ """
2
+ Anomaly detection on model residuals.
3
+ Running on residuals (actual - predicted) catches anomalies the model
4
+ didn't predict — far more useful than flagging obvious raw-value spikes.
5
+ Three methods: IsolationForest, Z-score on residuals, IQR on residuals.
6
+ """
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ from sklearn.ensemble import IsolationForest
11
+ from typing import Optional
12
+
13
+
14
+ class AnomalyDetector:
15
+ SEVERITY_LABELS = {0: "normal", 1: "suspicious", 2: "anomaly"}
16
+
17
+ def __init__(
18
+ self,
19
+ method: str = "ensemble",
20
+ contamination: float = 0.05,
21
+ z_threshold: float = 3.0,
22
+ window: int = 30,
23
+ ):
24
+ self.method = method
25
+ self.contamination = contamination
26
+ self.z_threshold = z_threshold
27
+ self.window = window
28
+ self._iso: Optional[IsolationForest] = None
29
+ self.audit: dict = {}
30
+
31
+ def fit_predict(
32
+ self,
33
+ y_true: pd.Series,
34
+ y_pred: np.ndarray,
35
+ X: Optional[pd.DataFrame] = None,
36
+ ) -> pd.DataFrame:
37
+ """
38
+ Returns a DataFrame with columns:
39
+ actual, predicted, residual, anomaly_score,
40
+ anomaly_flag (0/1), severity, anomaly_label
41
+ """
42
+ residuals = y_true.values - y_pred
43
+ index = y_true.index
44
+
45
+ result = pd.DataFrame(
46
+ {
47
+ "actual": y_true.values,
48
+ "predicted": y_pred,
49
+ "residual": residuals,
50
+ },
51
+ index=index,
52
+ )
53
+
54
+ # Method 1: Isolation Forest on residuals
55
+ iso_scores = self._iso_scores(residuals)
56
+
57
+ # Method 2: Rolling Z-score on residuals
58
+ zscore_flags = self._rolling_zscore_flags(residuals)
59
+
60
+ # Method 3: IQR flags
61
+ iqr_flags = self._iqr_flags(residuals)
62
+
63
+ if self.method == "ensemble":
64
+ # Vote: flag if 2+ methods agree
65
+ votes = iso_scores + zscore_flags.astype(int) + iqr_flags.astype(int)
66
+ result["anomaly_score"] = iso_scores.astype(float) + np.abs(residuals) / (np.std(residuals) + 1e-9)
67
+ result["anomaly_flag"] = (votes >= 2).astype(int)
68
+ elif self.method == "isolation_forest":
69
+ result["anomaly_score"] = iso_scores.astype(float)
70
+ result["anomaly_flag"] = iso_scores
71
+ elif self.method == "zscore":
72
+ result["anomaly_score"] = np.abs(residuals) / (np.std(residuals) + 1e-9)
73
+ result["anomaly_flag"] = zscore_flags.astype(int)
74
+ else:
75
+ result["anomaly_score"] = np.abs(residuals) / (np.std(residuals) + 1e-9)
76
+ result["anomaly_flag"] = iqr_flags.astype(int)
77
+
78
+ # Severity: 0=normal, 1=suspicious (score > 1.5σ), 2=anomaly (flagged)
79
+ sigma = np.std(residuals) + 1e-9
80
+ result["severity"] = 0
81
+ result.loc[np.abs(residuals) > 1.5 * sigma, "severity"] = 1
82
+ result.loc[result["anomaly_flag"] == 1, "severity"] = 2
83
+ result["severity_label"] = result["severity"].map(self.SEVERITY_LABELS)
84
+
85
+ n_anomalies = int(result["anomaly_flag"].sum())
86
+ self.audit = {
87
+ "method": self.method,
88
+ "total_points": len(result),
89
+ "anomalies_found": n_anomalies,
90
+ "anomaly_pct": round(n_anomalies / len(result) * 100, 2),
91
+ "suspicious_count": int((result["severity"] == 1).sum()),
92
+ "residual_mean": round(float(np.mean(residuals)), 4),
93
+ "residual_std": round(float(np.std(residuals)), 4),
94
+ }
95
+ return result
96
+
97
+ def _iso_scores(self, residuals: np.ndarray) -> np.ndarray:
98
+ try:
99
+ iso = IsolationForest(
100
+ contamination=self.contamination, random_state=42, n_jobs=-1
101
+ )
102
+ preds = iso.fit_predict(residuals.reshape(-1, 1))
103
+ return (preds == -1).astype(int)
104
+ except Exception:
105
+ return np.zeros(len(residuals), dtype=int)
106
+
107
+ def _rolling_zscore_flags(self, residuals: np.ndarray) -> np.ndarray:
108
+ s = pd.Series(residuals)
109
+ roll_mean = s.rolling(self.window, min_periods=3).mean().fillna(s.mean())
110
+ roll_std = s.rolling(self.window, min_periods=3).std().fillna(s.std())
111
+ z = (s - roll_mean) / (roll_std + 1e-9)
112
+ return (z.abs() > self.z_threshold).values
113
+
114
+ def _iqr_flags(self, residuals: np.ndarray) -> np.ndarray:
115
+ q1, q3 = np.percentile(residuals, 25), np.percentile(residuals, 75)
116
+ iqr = q3 - q1
117
+ lower, upper = q1 - 2.5 * iqr, q3 + 2.5 * iqr
118
+ return (residuals < lower) | (residuals > upper)
@@ -0,0 +1,4 @@
1
+ from baxter_ts.bax.explainer import BAXExplainer
2
+ from baxter_ts.bax.narrator import BAXNarrator
3
+
4
+ __all__ = ["BAXExplainer", "BAXNarrator"]
@@ -0,0 +1,58 @@
1
+ """
2
+ BAX Explainer: computes SHAP values on the winning model.
3
+ Works with tree-based models (RF, XGB, CatBoost) via TreeExplainer.
4
+ """
5
+
6
+ import warnings
7
+ import numpy as np
8
+ import pandas as pd
9
+ from typing import Dict, List, Optional
10
+
11
+
12
+ class BAXExplainer:
13
+ def __init__(self):
14
+ self._explainer = None
15
+ self.shap_values_: Optional[np.ndarray] = None
16
+ self.feature_importance_: Optional[pd.Series] = None
17
+ self.top_features_: List[str] = []
18
+ self.audit: dict = {}
19
+
20
+ def fit(self, model, X_sample: pd.DataFrame) -> "BAXExplainer":
21
+ """Fit SHAP TreeExplainer on a sample of training data."""
22
+ try:
23
+ import shap
24
+ sample = X_sample.dropna()
25
+ if len(sample) > 500:
26
+ sample = sample.sample(500, random_state=42)
27
+
28
+ raw_model = model._model
29
+ with warnings.catch_warnings():
30
+ warnings.simplefilter("ignore")
31
+ self._explainer = shap.TreeExplainer(raw_model)
32
+ self.shap_values_ = self._explainer.shap_values(sample)
33
+
34
+ mean_abs = np.abs(self.shap_values_).mean(axis=0)
35
+ self.feature_importance_ = pd.Series(
36
+ mean_abs, index=sample.columns
37
+ ).sort_values(ascending=False)
38
+ self.top_features_ = self.feature_importance_.head(10).index.tolist()
39
+ self.audit["shap_computed"] = True
40
+ self.audit["top_features"] = self.top_features_
41
+ except Exception as e:
42
+ self.audit["shap_computed"] = False
43
+ self.audit["shap_error"] = str(e)
44
+ return self
45
+
46
+ def explain_prediction(
47
+ self, model, X_row: pd.DataFrame
48
+ ) -> Dict[str, float]:
49
+ """Returns per-feature SHAP contribution for a single row."""
50
+ if self._explainer is None:
51
+ return {}
52
+ try:
53
+ import shap
54
+ row = X_row.dropna(axis=1).fillna(0)
55
+ vals = self._explainer.shap_values(row)
56
+ return dict(zip(row.columns, vals[0]))
57
+ except Exception:
58
+ return {}
@@ -0,0 +1,128 @@
1
+ """
2
+ BAX Narrator: translates SHAP feature importances into plain-English
3
+ behavioural explanations. This is the core differentiator of baxter-ts.
4
+ """
5
+
6
+ import pandas as pd
7
+ import numpy as np
8
+ from typing import Optional
9
+
10
+
11
+ class BAXNarrator:
12
+ FEATURE_PHRASES = {
13
+ "lag_": "recent past values (lag-{n})",
14
+ "roll_mean_": "rolling average over {n} periods",
15
+ "roll_std_": "recent volatility ({n}-period std dev)",
16
+ "seasonal_": "seasonal pattern",
17
+ "trend_": "underlying trend",
18
+ "ewm_": "exponentially weighted recent momentum",
19
+ "dayofweek": "day-of-week effect",
20
+ "month": "month-of-year effect",
21
+ "hour": "hour-of-day effect",
22
+ "is_weekend": "weekend vs weekday pattern",
23
+ "fourier": "cyclical seasonality (Fourier term)",
24
+ "sin_": "cyclical seasonality (sine component)",
25
+ "cos_": "cyclical seasonality (cosine component)",
26
+ "time_idx": "long-run time trend",
27
+ "pct_change": "recent rate of change",
28
+ "residual_": "irregular/noise component",
29
+ "holiday": "holiday calendar effect",
30
+ "quarter": "quarterly pattern",
31
+ }
32
+
33
+ def generate(
34
+ self,
35
+ feature_importance: Optional[pd.Series],
36
+ model_name: str,
37
+ target_col: str,
38
+ test_scores: dict,
39
+ preprocessing_audit: dict,
40
+ ) -> str:
41
+ if feature_importance is None or len(feature_importance) == 0:
42
+ return self._fallback_narrative(model_name, target_col, test_scores)
43
+
44
+ top = feature_importance.head(10)
45
+ total_importance = top.sum()
46
+
47
+ lines = []
48
+ lines.append(
49
+ f"The {model_name} model was selected as the best performer for "
50
+ f"predicting '{target_col}', achieving a test MAE of "
51
+ f"{test_scores.get('mae', 'N/A')} and RMSE of "
52
+ f"{test_scores.get('rmse', 'N/A')}."
53
+ )
54
+ lines.append("")
55
+ lines.append("Key behavioural drivers (BAX analysis):")
56
+ lines.append("")
57
+
58
+ cumulative = 0.0
59
+ for rank, (feat, importance) in enumerate(top.items(), start=1):
60
+ pct = round(importance / (total_importance + 1e-9) * 100, 1)
61
+ cumulative += pct
62
+ phrase = self._describe_feature(feat)
63
+ direction = ""
64
+ lines.append(
65
+ f" {rank}. {phrase} accounts for {pct}% of prediction influence{direction}."
66
+ )
67
+ if cumulative >= 80:
68
+ break
69
+
70
+ lines.append("")
71
+ lines.append(self._preprocessing_summary(preprocessing_audit))
72
+ lines.append("")
73
+ lines.append(
74
+ "Note: Contributions are computed using SHAP (SHapley Additive exPlanations), "
75
+ "which fairly attributes prediction influence across all features while "
76
+ "respecting feature interactions."
77
+ )
78
+ return "\n".join(lines)
79
+
80
+ def _describe_feature(self, feature_name: str) -> str:
81
+ for prefix, template in self.FEATURE_PHRASES.items():
82
+ if prefix in feature_name:
83
+ n = ""
84
+ parts = feature_name.split("_")
85
+ for part in parts:
86
+ if part.isdigit():
87
+ n = part
88
+ break
89
+ return template.replace("{n}", n) if n else template.replace(" ({n})", "").replace("{n}", "")
90
+ return f"feature '{feature_name}'"
91
+
92
+ def _preprocessing_summary(self, audit: dict) -> str:
93
+ parts = []
94
+ v = audit.get("validator", {})
95
+ i = audit.get("imputer", {})
96
+ o = audit.get("outlier", {})
97
+ t = audit.get("transformer", {})
98
+ sc = audit.get("scaler", {})
99
+
100
+ if v.get("inferred_frequency"):
101
+ parts.append(f"Data frequency detected as '{v['inferred_frequency']}'")
102
+ if i.get("missing_pct", 0) > 0:
103
+ parts.append(
104
+ f"{i['missing_pct']}% missing values filled using '{i.get('strategy_used', 'auto')}'"
105
+ )
106
+ if o.get("outliers_found", 0) > 0:
107
+ parts.append(
108
+ f"{o['outliers_found']} outliers ({o.get('outlier_pct', '')}%) "
109
+ f"treated via {o.get('outlier_treatment', 'cap')}"
110
+ )
111
+ if t.get("diffs_applied", 0) > 0:
112
+ parts.append(f"{t['diffs_applied']}-order differencing applied for stationarity")
113
+ if t.get("log_transform_applied"):
114
+ parts.append("log(1+x) transform applied to reduce skewness")
115
+ if sc.get("scaler_used"):
116
+ parts.append(f"{sc['scaler_used']} scaling applied")
117
+
118
+ if parts:
119
+ return "Preprocessing applied: " + "; ".join(parts) + "."
120
+ return ""
121
+
122
+ def _fallback_narrative(self, model_name: str, target_col: str, test_scores: dict) -> str:
123
+ return (
124
+ f"{model_name} was selected to forecast '{target_col}'. "
125
+ f"Test MAE: {test_scores.get('mae', 'N/A')}, "
126
+ f"RMSE: {test_scores.get('rmse', 'N/A')}. "
127
+ "SHAP explanation unavailable for this model configuration."
128
+ )