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 +19 -0
- baxter_ts/anomaly/__init__.py +3 -0
- baxter_ts/anomaly/detector.py +118 -0
- baxter_ts/bax/__init__.py +4 -0
- baxter_ts/bax/explainer.py +58 -0
- baxter_ts/bax/narrator.py +128 -0
- baxter_ts/core.py +483 -0
- baxter_ts/models/__init__.py +6 -0
- baxter_ts/models/base_model.py +87 -0
- baxter_ts/models/catboost_model.py +23 -0
- baxter_ts/models/rf_model.py +21 -0
- baxter_ts/models/selector.py +78 -0
- baxter_ts/models/xgb_model.py +26 -0
- baxter_ts/preprocessing/__init__.py +19 -0
- baxter_ts/preprocessing/column_handler.py +135 -0
- baxter_ts/preprocessing/feature_eng.py +142 -0
- baxter_ts/preprocessing/imputer.py +78 -0
- baxter_ts/preprocessing/outlier.py +87 -0
- baxter_ts/preprocessing/scaler.py +62 -0
- baxter_ts/preprocessing/splitter.py +48 -0
- baxter_ts/preprocessing/transformer.py +92 -0
- baxter_ts/preprocessing/validator.py +120 -0
- baxter_ts/report/__init__.py +3 -0
- baxter_ts/report/generator.py +342 -0
- baxter_ts/visualization/__init__.py +3 -0
- baxter_ts/visualization/plotter.py +290 -0
- baxter_ts-0.1.1.dist-info/METADATA +479 -0
- baxter_ts-0.1.1.dist-info/RECORD +31 -0
- baxter_ts-0.1.1.dist-info/WHEEL +5 -0
- baxter_ts-0.1.1.dist-info/licenses/LICENSE +21 -0
- baxter_ts-0.1.1.dist-info/top_level.txt +1 -0
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,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,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
|
+
)
|