Skip to content

Commit 7a03ded

Browse files
Add ordinal + calibration metrics, CV reporting, and CI-built model artifact (#2)
* Update ci.yml * Update .gitignore * Add files via upload
1 parent e915de7 commit 7a03ded

12 files changed

Lines changed: 402 additions & 27 deletions

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,8 @@ env/
2323

2424
# Scored outputs produced at runtime
2525
*_scored.csv
26+
27+
# Trained model is a build artifact (produced by `python -m src.train_model`
28+
# and by CI), not committed. Small reporting figures/metrics are kept for the
29+
# README to render.
30+
models/*.joblib

README.md

Lines changed: 56 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,37 @@ The pipeline performs:
3939

4040
Trained on 1,200 sessions, evaluated on a held-out 300-session test set:
4141

42-
| Metric | Value |
43-
| ------ | ----- |
44-
| Test accuracy | **0.483** |
45-
| Majority-class baseline | 0.200 |
46-
| Lift over baseline | **+0.283** |
47-
| Macro F1 | ~0.47 |
42+
| Metric | Value | What it tells you |
43+
| ------ | ----- | ----------------- |
44+
| Test accuracy | **0.483** | Exact-rating hit rate (single split) |
45+
| 5-fold CV accuracy | **0.429 ± 0.036** | Stable across folds — the split above isn't a lucky one |
46+
| Majority-class baseline | 0.200 | Chance for a balanced 5-class target |
47+
| Lift over baseline | **+0.283** | ~2.4× baseline |
48+
| Quadratic-weighted kappa | **0.726** | Agreement that rewards *near* predictions (ordinal quality) |
49+
| Mean absolute error | **0.71** | Average rating distance — most misses are off-by-one |
50+
| Log loss | 1.25 | Probability quality |
51+
| Expected calibration error | 0.091 | Confidence-vs-accuracy gap (see reliability diagram) |
4852

49-
Chance for a balanced 5-class problem is 0.20, so the model reaches roughly **2.4×** the baseline. This is a deliberately honest, moderate result: satisfaction is noisy by construction, and adjacent ratings are genuinely hard to separate — which is what a real ordinal satisfaction signal looks like. Metrics are written to [`reports/metrics/metrics.json`](reports/metrics/metrics.json) on every training run.
53+
The single-split accuracy (0.483) and the cross-validated mean (0.429 ± 0.036) are reported together so the headline isn't cherry-picked. The high **quadratic-weighted kappa (0.73)** is the important number for an ordered target: the model rarely makes large errors — most mistakes are adjacent ratings. All values are written to [`reports/metrics/metrics.json`](reports/metrics/metrics.json) on every training run.
54+
55+
### Is ordinal regression better here?
56+
57+
The 1–5 target is ordered, so a natural question is whether a dedicated **ordinal** model beats flat multi-class. This is tested reproducibly in [`src/evaluate.py`](src/evaluate.py) (`compare_ordinal_vs_flat`), which fits a Frank & Hall ordinal wrapper ([`src/ordinal.py`](src/ordinal.py)) on the same split and writes [`reports/metrics/ordinal_comparison.json`](reports/metrics/ordinal_comparison.json):
58+
59+
| Model | Accuracy | MAE | QWK |
60+
| ----- | -------- | --- | --- |
61+
| Flat RandomForest (default) | **0.483** | **0.71** | **0.726** |
62+
| Frank & Hall ordinal RF | 0.397 | 1.01 | 0.640 |
63+
64+
The flat forest wins on every metric — it already respects the ordering via `class_weight="balanced"` and the SHAP-visible structure — so it stays the default. The ordinal model is kept as a documented, tested alternative rather than dropped, so the conclusion is checkable, not asserted.
65+
66+
### Calibration
67+
68+
`predict_proba` reliability is measured with a reliability diagram and Expected Calibration Error (ECE):
69+
70+
<img src="reports/figures/reliability_diagram.png" width="480" alt="reliability diagram" />
71+
72+
The RandomForest is moderately calibrated (ECE ≈ 0.09). Post-hoc **sigmoid (Platt) recalibration** reduces ECE to ≈ 0.06 but costs ~3 points of accuracy, so it is reported rather than applied by default; enable it downstream with `sklearn.calibration.CalibratedClassifierCV` if trustworthy probabilities matter more than exact-rating accuracy for your use case.
5073

5174
---
5275

@@ -64,17 +87,19 @@ AI-Assistant-Satisfaction-Prediction-Engine/
6487
│ ├── sessions_train.csv
6588
│ └── sessions_test.csv
6689
67-
├── models/
68-
│ └── satisfaction_pipeline.joblib # Trained ML pipeline
90+
├── models/ # satisfaction_pipeline.joblib is a
91+
│ └── .gitkeep # build artifact (git-ignored, see below)
6992
7093
├── reports/
7194
│ ├── metrics/
7295
│ │ ├── metrics.json
73-
│ │ └── classification_report.json
96+
│ │ ├── classification_report.json
97+
│ │ └── ordinal_comparison.json # flat-RF vs ordinal experiment
7498
│ └── figures/
7599
│ ├── confusion_matrix.png
76100
│ ├── satisfaction_distribution.png
77101
│ ├── per_class_f1.png
102+
│ ├── reliability_diagram.png # calibration curve
78103
│ └── shap_summary.png
79104
80105
├── src/
@@ -85,13 +110,15 @@ AI-Assistant-Satisfaction-Prediction-Engine/
85110
│ ├── data_prep.py
86111
│ ├── features.py
87112
│ ├── shap_utils.py # Version-robust SHAP shape helpers
113+
│ ├── model_metrics.py # Ordinal + calibration metric helpers
114+
│ ├── ordinal.py # Frank & Hall ordinal classifier
88115
│ ├── train_model.py
89116
│ ├── evaluate.py
90117
│ ├── explain.py
91118
│ └── score_new_sessions.py
92119
93-
├── tests/ # pytest suite
94-
├── .github/workflows/ci.yml # ruff + black + mypy + pytest
120+
├── tests/ # pytest suite (20 tests)
121+
├── .github/workflows/ci.yml # gate + full-pipeline job w/ artifacts
95122
├── pyproject.toml # Tooling configuration
96123
├── requirements.txt
97124
├── LICENSE
@@ -222,12 +249,17 @@ pip install -r requirements.txt
222249

223250
python -m src.generate_data # 1) create the synthetic raw dataset
224251
python -m src.data_prep # 2) time features + stratified train/test split
225-
python -m src.train_model # 3) train, evaluate, save model + metrics
226-
python -m src.evaluate # 4) confusion matrix, distribution, per-class F1
252+
python -m src.train_model # 3) train, save model, write accuracy/CV/QWK/MAE metrics
253+
python -m src.evaluate # 4) confusion matrix, distribution, per-class F1,
254+
# reliability diagram, ordinal-vs-flat comparison
227255
python -m src.explain # 5) SHAP global summary
228256
streamlit run app.py # 6) launch the dashboard
229257
```
230258

259+
## Model artifact
260+
261+
`models/satisfaction_pipeline.joblib` is a **build artifact**, not committed to git (it is `.gitignore`d). Because the whole pipeline is deterministic, you reproduce it exactly with `python -m src.train_model`. CI also trains it on every run and publishes it (plus `reports/`) as a downloadable **GitHub Actions artifact**. Run steps 1–3 above before launching the dashboard or scoring new sessions.
262+
231263
---
232264

233265
# **Quality Gate**
@@ -238,10 +270,12 @@ This repo ships a CI-enforced quality gate (see [`.github/workflows/ci.yml`](.gi
238270
ruff check . # lint (E,F,I,B,SIM,UP), line-length 100
239271
black --check . # formatting, line-length 100
240272
mypy src app.py # static type checking
241-
pytest # unit tests (heavy shap/streamlit tests skip cleanly)
273+
pytest # 20 unit tests (heavy shap/streamlit tests skip cleanly)
242274
```
243275

244-
Tests cover time-feature engineering, the training pipeline, the stratified split, batch scoring round-trips, the documented data signal, and the version-robust SHAP shape helpers.
276+
CI runs two jobs: a **quality-gate** job (the four commands above, on Python 3.10 and 3.12) and a **pipeline** job that executes the full `generate_data → data_prep → train_model → evaluate → explain` chain end-to-end and uploads the trained model and reports as artifacts — so the training path itself is exercised in CI, not just the unit tests.
277+
278+
The 20 tests cover time-feature engineering, the training pipeline, the stratified split, batch scoring round-trips, the documented data signal, the version-robust SHAP shape helpers, the ordinal (Frank & Hall) classifier, and the calibration/ordinal metric helpers (ECE, MAE, QWK).
245279

246280
---
247281

@@ -273,19 +307,18 @@ Python 3.10+, pandas, numpy, scikit-learn, matplotlib, seaborn, SHAP, Streamlit,
273307

274308
# **Limitations**
275309

276-
* Synthetic data: results reflect a *designed* signal, not real users. Real satisfaction would be noisier and confounded.
277-
* Moderate accuracy (~0.48) is intentional — the noise term keeps the task realistic.
278-
* Treated as flat multi-class rather than ordinal.
310+
* Synthetic data: results reflect a *designed* signal, not real users. Real satisfaction would be noisier and confounded — this is the main bound on the findings.
311+
* Moderate accuracy (~0.48, CV 0.43) is intentional — the noise term keeps the task realistic; quadratic-weighted kappa (0.73) shows errors are mostly adjacent.
312+
* The target is ordered; a Frank & Hall ordinal model was evaluated and did **not** beat the flat forest here (see Results), so flat multi-class is retained.
279313
* No per-user personalization or temporal modeling.
280314

281315
---
282316

283317
# **Future Work**
284318

285-
* Reframe as **ordinal regression** to exploit the natural ordering.
286-
* Add calibration plots and probability calibration.
287-
* Add temporal / sequence features per user.
288-
* Deploy the scorer as a FastAPI microservice.
319+
* Validate on a **real** (non-synthetic) dataset — the single biggest open item.
320+
* Add temporal / sequence features per user and per-user personalization.
321+
* Deploy the scorer as a FastAPI microservice with the CI-built model artifact.
289322

290323
---
291324

34.9 KB
Loading

reports/figures/shap_summary.png

-7 Bytes
Loading

reports/metrics/metrics.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,11 @@
22
"accuracy": 0.48333333333333334,
33
"baseline_accuracy": 0.2,
44
"lift_over_baseline": 0.2833333333333333,
5-
"n_test_samples": 300
5+
"cv_accuracy_mean": 0.4293333333333333,
6+
"cv_accuracy_std": 0.03561522770451489,
7+
"mae": 0.7133333333333334,
8+
"quadratic_weighted_kappa": 0.725609756097561,
9+
"log_loss": 1.2513373472029916,
10+
"n_test_samples": 300,
11+
"random_state": 42
612
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"flat_rf": {
3+
"accuracy": 0.48333333333333334,
4+
"mae": 0.7133333333333334,
5+
"quadratic_weighted_kappa": 0.725609756097561
6+
},
7+
"ordinal_frank_hall": {
8+
"accuracy": 0.39666666666666667,
9+
"mae": 1.0066666666666666,
10+
"quadratic_weighted_kappa": 0.6404109589041096
11+
}
12+
}

src/evaluate.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import numpy as np
88
import pandas as pd
99
import seaborn as sns
10-
from sklearn.metrics import classification_report, confusion_matrix
10+
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
1111

1212
from .config import (
1313
FIGURES_DIR,
@@ -16,6 +16,13 @@
1616
PROCESSED_DATA_DIR,
1717
TARGET_COL,
1818
)
19+
from .features import build_model, build_preprocessor
20+
from .model_metrics import (
21+
calibration_curve_points,
22+
expected_calibration_error,
23+
ordinal_metrics,
24+
)
25+
from .ordinal import OrdinalClassifier
1926

2027

2128
def load_model_and_data():
@@ -99,12 +106,76 @@ def plot_per_class_f1(report_dict) -> None:
99106
print(f"Saved per-class F1 plot to: {f1_path}")
100107

101108

109+
def plot_reliability_diagram(y_true, proba, classes) -> float:
110+
"""Save a reliability diagram and return the Expected Calibration Error."""
111+
conf, acc, counts = calibration_curve_points(y_true, proba, classes)
112+
ece = expected_calibration_error(y_true, proba, classes)
113+
114+
fig, ax = plt.subplots()
115+
ax.plot([0, 1], [0, 1], "--", color="grey", label="Perfectly calibrated")
116+
ax.plot(conf, acc, "o-", label="Model")
117+
ax.set_xlabel("Mean predicted confidence")
118+
ax.set_ylabel("Empirical accuracy")
119+
ax.set_title(f"Reliability Diagram (ECE = {ece:.3f})")
120+
ax.set_xlim(0, 1)
121+
ax.set_ylim(0, 1)
122+
ax.legend()
123+
124+
path = FIGURES_DIR / "reliability_diagram.png"
125+
plt.tight_layout()
126+
plt.savefig(path)
127+
plt.close()
128+
print(f"Saved reliability diagram to: {path} (ECE={ece:.3f})")
129+
return ece
130+
131+
132+
def compare_ordinal_vs_flat() -> dict:
133+
"""Fit a flat RF and a Frank-Hall ordinal RF on the same split and compare.
134+
135+
Makes the "should this be ordinal regression?" question reproducible. On the
136+
synthetic data the flat forest wins on every metric, so it stays the default.
137+
"""
138+
train_df = pd.read_csv(PROCESSED_DATA_DIR / "sessions_train.csv")
139+
test_df = pd.read_csv(PROCESSED_DATA_DIR / "sessions_test.csv")
140+
141+
X_train = train_df.drop(columns=[TARGET_COL])
142+
y_train = train_df[TARGET_COL]
143+
X_test = test_df.drop(columns=[TARGET_COL])
144+
y_test = test_df[TARGET_COL]
145+
146+
pre = build_preprocessor().fit(X_train)
147+
Xtr, Xte = pre.transform(X_train), pre.transform(X_test)
148+
149+
flat = build_model().fit(Xtr, y_train)
150+
flat_pred = flat.predict(Xte)
151+
152+
ordinal = OrdinalClassifier(build_model()).fit(Xtr, y_train.values)
153+
ord_pred = ordinal.predict(Xte)
154+
155+
def _summary(pred) -> dict:
156+
return {"accuracy": float(accuracy_score(y_test, pred)), **ordinal_metrics(y_test, pred)}
157+
158+
result = {
159+
"flat_rf": _summary(flat_pred),
160+
"ordinal_frank_hall": _summary(ord_pred),
161+
}
162+
out = METRICS_DIR / "ordinal_comparison.json"
163+
with out.open("w") as f:
164+
json.dump(result, f, indent=2)
165+
print(f"Saved ordinal-vs-flat comparison to: {out}")
166+
print(f" flat RF : {result['flat_rf']}")
167+
print(f" ordinal : {result['ordinal_frank_hall']}")
168+
return result
169+
170+
102171
def main() -> None:
103172
model, X_test, y_test = load_model_and_data()
104173
y_pred = model.predict(X_test)
174+
proba = model.predict_proba(X_test)
105175

106176
plot_confusion_matrix(y_test, y_pred)
107177
plot_satisfaction_distribution(y_test, y_pred)
178+
plot_reliability_diagram(y_test, proba, model.classes_)
108179

109180
report = classification_report(y_test, y_pred, output_dict=True, digits=3)
110181
report_path = METRICS_DIR / "classification_report.json"
@@ -113,6 +184,7 @@ def main() -> None:
113184
print(f"Saved updated classification report to: {report_path}")
114185

115186
plot_per_class_f1(report)
187+
compare_ordinal_vs_flat()
116188

117189

118190
if __name__ == "__main__":

src/model_metrics.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Evaluation metric helpers.
2+
3+
Small, dependency-light functions so the extra reporting (ordinal quality,
4+
probability calibration, cross-validated stability) is unit-testable and shared
5+
between ``train_model`` and ``evaluate``.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import numpy as np
11+
from sklearn.metrics import cohen_kappa_score, log_loss, mean_absolute_error
12+
13+
14+
def ordinal_metrics(y_true, y_pred) -> dict:
15+
"""Mean absolute error and quadratic-weighted kappa.
16+
17+
Both reward predictions that land *near* the true rating, which is what we
18+
care about for an ordered 1-5 target -- plain accuracy treats "off by one"
19+
and "off by four" identically.
20+
"""
21+
return {
22+
"mae": float(mean_absolute_error(y_true, y_pred)),
23+
"quadratic_weighted_kappa": float(cohen_kappa_score(y_true, y_pred, weights="quadratic")),
24+
}
25+
26+
27+
def expected_calibration_error(y_true, proba, classes, n_bins: int = 10) -> float:
28+
"""Expected Calibration Error of the top-1 prediction.
29+
30+
Bins predictions by confidence and averages |accuracy - confidence| weighted
31+
by bin population. 0 = perfectly calibrated.
32+
"""
33+
y_true = np.asarray(y_true)
34+
proba = np.asarray(proba)
35+
classes = np.asarray(classes)
36+
37+
confidence = proba.max(axis=1)
38+
predictions = classes[proba.argmax(axis=1)]
39+
correct = (predictions == y_true).astype(float)
40+
41+
edges = np.linspace(0.0, 1.0, n_bins + 1)
42+
ece = 0.0
43+
n = len(y_true)
44+
for i in range(n_bins):
45+
lo, hi = edges[i], edges[i + 1]
46+
mask = (confidence > lo) & (confidence <= hi)
47+
count = int(mask.sum())
48+
if count:
49+
ece += abs(correct[mask].mean() - confidence[mask].mean()) * count / n
50+
return float(ece)
51+
52+
53+
def calibration_curve_points(y_true, proba, classes, n_bins: int = 10):
54+
"""Return (mean_confidence, accuracy, count) per confidence bin for plotting."""
55+
y_true = np.asarray(y_true)
56+
proba = np.asarray(proba)
57+
classes = np.asarray(classes)
58+
59+
confidence = proba.max(axis=1)
60+
predictions = classes[proba.argmax(axis=1)]
61+
correct = (predictions == y_true).astype(float)
62+
63+
edges = np.linspace(0.0, 1.0, n_bins + 1)
64+
conf_pts, acc_pts, counts = [], [], []
65+
for i in range(n_bins):
66+
lo, hi = edges[i], edges[i + 1]
67+
mask = (confidence > lo) & (confidence <= hi)
68+
if mask.sum():
69+
conf_pts.append(float(confidence[mask].mean()))
70+
acc_pts.append(float(correct[mask].mean()))
71+
counts.append(int(mask.sum()))
72+
return np.array(conf_pts), np.array(acc_pts), np.array(counts)
73+
74+
75+
def multiclass_log_loss(y_true, proba, classes) -> float:
76+
return float(log_loss(y_true, proba, labels=list(classes)))

0 commit comments

Comments
 (0)