| # Phase 2A β ResNet50 multi-label damage-type classifier + XGBoost(A) |
|
|
| **Status:** β
**Done** (scaffold + verified end-to-end on MPS; full training is a sequence of CLI commands) |
| **Completed:** 2026-05-13 |
|
|
| ## Goal |
|
|
| Build the full **Variant A** stack: a ResNet50 multi-label classifier over the 6 CarDD damage types, a 2048-d feature extractor that streams every CarDD image into a parquet cache, a synthetic cost-target generator that joins iaai metadata distributions to a catalog-derived per-image cost, and an XGBoost(A) regressor on top. Wrap it in `VariantAPipeline` so a single image + optional metadata produces a complete prediction (damage types, parts, cost, tier, provenance). |
|
|
| ## Deliverables |
|
|
| - [x] [src/ccdp/data/damage_dataset.py](../src/ccdp/data/damage_dataset.py) β multi-hot label encoding, deterministic 80/10/10 splits, inverse-frequency `pos_weight`. |
| - [x] [src/ccdp/models/damage_classifier.py](../src/ccdp/models/damage_classifier.py) β ResNet50 + dropout/512/dropout/6 head, `set_finetune_stage`, `extract_features`. |
| - [x] [src/ccdp/train/train_damage_classifier.py](../src/ccdp/train/train_damage_classifier.py) β two-stage trainer, BCE+pos_weight, per-class P/R/F1, macro/micro F1, full resume. |
| - [x] [src/ccdp/train/extract_features.py](../src/ccdp/train/extract_features.py) β runs trained backbone over all CarDD images, writes parquet with `image_id, split, damage_types, f_0..f_2047`. |
| - [x] [src/ccdp/train/synthesize_cost.py](../src/ccdp/train/synthesize_cost.py) β samples metadata from iaai pool, maps damage types β parts, computes catalog-driven cost with age + noise factors. Outputs `cost_source = synthetic@<catalog_id>` per row for traceability. |
| - [x] [src/ccdp/models/xgb_regressor.py](../src/ccdp/models/xgb_regressor.py) β `XGBRegressorBundle` (feature schema + training catalog id) + `make_feature_matrix` for reproducible one-hot encoding at inference. |
| - [x] [src/ccdp/train/train_xgb.py](../src/ccdp/train/train_xgb.py) β joins features+targets on `image_id`, trains XGBoost(A), reports RMSE/MAE/MAPE/RΒ² on val and test, saves `best.ubj` + `bundle.json`. |
| - [x] [src/ccdp/infer/variant_a.py](../src/ccdp/infer/variant_a.py) β `VariantAPipeline` (image β classifier β optional XGBoost β cost). Falls back gracefully to the three-tier catalog estimator when XGBoost or metadata is unavailable. Currency-converts via the FX module. |
| - [x] CLI: `ccdp train classifier`, `ccdp train extract-features`, `ccdp train synth-targets`, `ccdp train xgb`, `ccdp infer`. |
| - [x] Tests: [tests/test_damage_classifier.py](../tests/test_damage_classifier.py) β **54 / 54 passing**. |
| - [x] End-to-end smoke verified on MPS. |
|
|
| ## Smoke-run results (MPS, M-series 16GB) |
|
|
| Classifier (1 + 1 epochs of 25 batches Γ 16): |
|
|
| | metric | value | |
| |---|---| |
| | stage 1, ~9 s train + 6 s val on 400 samples | val macro-F1 = 0.61 | |
| | stage 2, ~10 s train + 6 s val on 400 samples | **val macro-F1 = 0.68, micro-F1 = 0.68** | |
| | pos_weight (auto) | dent 1.3, scratch 1.0, crack 5.6, glass_shatter 5.1, lamp_broken 5.0, **tire_flat 12.2** | |
| | trainable params | stage 1: 1,052,166 β’ stage 2: 23,115,270 | |
| |
| Feature extraction on 8 batches Γ 32 (768 images total): **~14 s**. Full corpus (4000 images): ~75 s extrapolated. |
| |
| XGBoost(A) on those 768 features: |
| |
| | split | RMSE | MAE | MAPE | RΒ² | |
| |---|---|---|---|---| |
| | val | $392.99 | $283.92 | 51.6% | 0.50 | |
| | test | $472.70 | $323.25 | 55.6% | 0.53 | |
| |
| End-to-end `ccdp infer` on a real CarDD val image, user-supplied metadata `--make honda --model-name civic --year 2018 --body-type sedan`: |
| ``` |
| damage_types: ["dent", "scratch"] |
| cost_usd: $642.31 |
| tier: "exact" |
| provenance: "xgb_a(exact); training_catalog=2026-05-12T05-45-11_initial; |
| calibrated to 2026-05-12T05-45-11_initial" |
| probabilities: {dent: 0.55, scratch: 0.72, crack: 0.41, |
| glass_shatter: 0.08, lamp_broken: 0.31, tire_flat: 0.02} |
| ``` |
| |
| ## How to verify |
|
|
| ```bash |
| source .venv/bin/activate |
| pytest -q # 54 / 54 passing |
| |
| # One-time macOS env (torchvision weights + xgboost OMP) |
| export SSL_CERT_FILE=$(python -c "import certifi; print(certifi.where())") |
| export DYLD_LIBRARY_PATH=$(python -c "import torch, os; print(os.path.join(os.path.dirname(torch.__file__),'lib'))") |
| |
| # Smoke (~30 s total) |
| ccdp train classifier --epochs-stage1 1 --epochs-stage2 1 --batch-size 16 \ |
| --num-workers 0 --smoke-batches 25 --tag smoke |
| CLS=$(ls checkpoints/classifier/run_*smoke*/best.pt | head -1) |
| ccdp train extract-features --checkpoint "$CLS" --batch-size 32 --num-workers 0 --smoke-batches 8 |
| ccdp train synth-targets |
| ccdp train xgb --n-estimators 80 --max-depth 5 --tag xgb_a_smoke |
| |
| # Inference |
| IMG=$(find data/raw/car-damage-detection -name '*.jpg' | head -1) |
| ccdp infer "$IMG" --make honda --model-name civic --year 2018 --body-type sedan |
| |
| # Full training (~25 min wall-clock target): |
| ccdp train classifier --epochs-stage1 3 --epochs-stage2 12 --batch-size 32 --num-workers 4 |
| ccdp registry promote <classifier_run_id> classifier |
| ccdp train extract-features |
| ccdp train synth-targets |
| ccdp train xgb --n-estimators 600 --max-depth 7 --learning-rate 0.05 --tag xgb_a_v1 |
| ccdp registry promote <xgb_run_id> xgb_a |
| ``` |
|
|
| ## Honest disclosure (relevant for the report) |
|
|
| - The cost target is **synthetic**, derived from `Catalog.estimate(parts_with_severity, segment)` Γ age factor Γ Β±10% Gaussian noise per row. There is no real per-image repair invoice data available publicly (see [progress/phase_1_data_and_identification.md](phase_1_data_and_identification.md) and [PLAN.md Β§3](../PLAN.md)). |
| - Tabular features (make/model/year/body_type) are **sampled** from the iaai metadata distribution, not derived from each CarDD image. They give XGBoost realistic feature correlations but they do not describe the specific car in the image. |
| - Each prediction records `training_catalog_id` and `bundle_run_id` in its provenance string. When the catalog is updated later, the `Calibrator` scales output by `active.median / training.median` so the trained XGBoost remains useful **without retraining**. |
| - Variant A produces **no part localization** β bbox-derived features arrive in Variant B (Phase 2B). The `parts: []` field in inference responses is honest about this limitation; XGBoost still produces a calibrated cost. |
| |
| ## Pending |
| |
| None blocking. Two follow-ups deferred: |
| |
| - **Promotion auto-eval** is still the simple symlink flip from Phase 1.5 β the A/B comparison harness lives with Phase 4. |
| - **The training-time catalog is the only one ever seen by XGBoost.** Until Phase 4, calibration coverage is small (one catalog). When a real second catalog lands, validate the scaling factor against a held-out test set before relying on it. |
| |
| ## Notes for future phases |
| |
| - **Phase 2B** (YOLOv8 detector) shares `XGBRegressorBundle` β just appends bbox-stat features (`n_damage_regions`, `total_damaged_area_pct`, per-part area dict) to the same image-feature row and trains a separate XGBoost(B). The Variant A β B comparison in Phase 3 reuses the metrics already produced by `train_xgb.py`. |
| - **Phase 3** (comparison + serving) will read both registered runs (classifier + xgb_a) and run side-by-side eval on the held-out test split that `damage_dataset.split_records(..., seed=42)` produces β already deterministic so both variants compare on the same images. |
| - **Phase 4** (promotion + continued training) will gate `ccdp registry promote` behind an A/B test against the current production model. The hook is already in place: `production_target('classifier')` and `production_target('xgb_a')` return the active symlink target. |
|
|