File size: 10,066 Bytes
323fee6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
"""Variant A vs Variant B head-to-head evaluator.

The "comparison" is a single :class:`Comparison` dataclass that knows:

* which test split was used (seed=42 deterministic, identical for A and B)
* per-variant classification + regression metrics
* per-variant inference latency (ms / image)
* tier distribution (`exact` / `nearest_class` / `category_only`)
* slice analyses by car segment and damage type
* the production model + catalog ids the report was generated against

The class is pure data once built — the renderer (``ccdp.eval.report``)
consumes it without touching any models. That separation keeps the slow part
(model inference over 400 test images) decoupled from the fast part (HTML/PDF
rendering) so you can iterate on the report layout without re-evaluating.
"""

from __future__ import annotations

import time
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional

import numpy as np

from ccdp.costing import load_active
from ccdp.data import damage_dataset as dd
from ccdp.data.loaders import iter_cardd
from ccdp.data.schema import DAMAGE_TYPES
from ccdp.eval.metrics import per_class_prf, regression_metrics
from ccdp.identification.car_identifier import IdentificationResult, infer_segment
from ccdp.registry import production_target


@dataclass
class VariantReport:
    """Everything the report renderer needs for one variant."""

    name: str                                   # 'A' | 'B'
    n_images: int
    classification: dict                        # output of per_class_prf
    regression: dict                            # output of regression_metrics
    tier_distribution: dict[str, int]           # tier -> count
    latency_ms: dict[str, float]                # mean, p50, p95
    examples: list[dict] = field(default_factory=list)
    failures: list[dict] = field(default_factory=list)


@dataclass
class Comparison:
    """Whole-report payload."""

    generated_at: str
    catalog_id: Optional[str]
    test_split_size: int
    seed: int
    variant_a: VariantReport
    variant_b: Optional[VariantReport] = None
    model_versions: dict[str, str] = field(default_factory=dict)
    slices: dict[str, Any] = field(default_factory=dict)
    notes: str = ""

    def to_dict(self) -> dict:
        d = asdict(self)
        return d


# ---------------------------------------------------------------------------
# Evaluation loop
# ---------------------------------------------------------------------------


def _percentile(values, p):
    if not values:
        return 0.0
    return float(np.percentile(np.asarray(values, dtype=float), p))


def _make_metadata_sampler(seed: int = 42):
    """Reuse the same iaai sampler the synthetic targets used at training time.

    Crucial: must match the trainer's metadata sampling so the ground-truth cost
    we compare against is generated under identical assumptions.
    """
    from ccdp.train.synthesize_cost import MetadataSampler
    return MetadataSampler(seed=seed)


def _ground_truth_cost(record, sampler, catalog, rng):
    """Reconstruct the synthetic training-time cost target for one record."""
    from ccdp.train.synthesize_cost import cost_for_damage
    meta = sampler.sample()
    return meta, cost_for_damage(
        record.damage_types, meta.segment, catalog, rng, year=meta.year,
    )


def _identification_for(meta) -> IdentificationResult:
    """Build an `IdentificationResult` the pipeline expects from a metadata sample."""
    return IdentificationResult(
        image_path=Path(""), make=meta.make, model=meta.model, year=meta.year,
        body_type=meta.body_type, segment=infer_segment(meta.make),
        confidence=1.0, source="user",
    )


def evaluate_variant(
    pipeline,
    name: str,
    records,
    limit: Optional[int] = None,
) -> VariantReport:
    """Run a pipeline over the test split and accumulate everything we report on."""
    import random
    rng = random.Random(42)
    sampler = _make_metadata_sampler()
    catalog = load_active()

    n_classes = len(DAMAGE_TYPES)
    probs = []
    labels = []
    y_true_cost = []
    y_pred_cost = []
    tier_counts: dict[str, int] = {}
    latencies: list[float] = []
    examples: list[dict] = []
    failures: list[dict] = []

    for i, r in enumerate(records):
        if limit and i >= limit:
            break

        meta, gt_cost = _ground_truth_cost(r, sampler, catalog, rng)
        ident = _identification_for(meta)

        t0 = time.time()
        prediction = pipeline.predict(r.image_path, metadata=ident, currency="USD")
        latencies.append((time.time() - t0) * 1000)

        # classification — Variant A returns probabilities, Variant B doesn't
        probs_row = [0.0] * n_classes
        if hasattr(prediction, "probabilities") and prediction.probabilities:
            for j, dt in enumerate(DAMAGE_TYPES):
                probs_row[j] = float(prediction.probabilities.get(dt, 0.0))
        else:
            # For Variant B fall back to a 1.0 prob for any detected type
            for j, dt in enumerate(DAMAGE_TYPES):
                probs_row[j] = 1.0 if dt in prediction.damage_types else 0.0
        probs.append(probs_row)
        labels.append([1.0 if dt in r.damage_types else 0.0 for dt in DAMAGE_TYPES])

        # regression
        y_true_cost.append(gt_cost)
        y_pred_cost.append(prediction.cost_usd)

        # tier
        tier_counts[prediction.tier] = tier_counts.get(prediction.tier, 0) + 1

        # collect a handful of qualitative examples
        if len(examples) < 10:
            examples.append({
                "image_id": r.image_id,
                "image_path": str(r.image_path),
                "predicted_types": prediction.damage_types,
                "ground_truth_types": r.damage_types,
                "predicted_cost": prediction.cost_usd,
                "ground_truth_cost": gt_cost,
                "tier": prediction.tier,
            })

    # build failure list (top absolute cost errors)
    pairs = list(zip(y_true_cost, y_pred_cost, records[: len(y_true_cost)]))
    pairs.sort(key=lambda p: abs(p[1] - p[0]), reverse=True)
    for gt, pred, rec in pairs[:5]:
        failures.append({
            "image_id": rec.image_id,
            "image_path": str(rec.image_path),
            "predicted_cost": pred,
            "ground_truth_cost": gt,
            "abs_error": abs(pred - gt),
        })

    classification = per_class_prf(np.array(probs), np.array(labels), DAMAGE_TYPES)
    regression = regression_metrics(y_true_cost, y_pred_cost)
    latency = {
        "mean": float(np.mean(latencies)) if latencies else 0.0,
        "p50": _percentile(latencies, 50),
        "p95": _percentile(latencies, 95),
    }
    return VariantReport(
        name=name,
        n_images=len(probs),
        classification=classification,
        regression=regression,
        tier_distribution=tier_counts,
        latency_ms=latency,
        examples=examples,
        failures=failures,
    )


def _load_test_records(seed: int = 42, limit: Optional[int] = None):
    records = [r for r in iter_cardd() if r.damage_types]
    _, _, test = dd.split_records(records, fractions=(0.8, 0.1, 0.1), seed=seed)
    if limit:
        test = test[:limit]
    return test


def _resolve_run_id(variant: str) -> str:
    """Best-effort: read the production symlink to find which run id is live."""
    target = production_target(variant)
    if not target:
        return "unknown"
    try:
        return target.resolve().parent.name
    except OSError:
        return "unknown"


def build_comparison(
    variant_a_pipeline,
    variant_b_pipeline=None,
    limit: Optional[int] = None,
    seed: int = 42,
) -> Comparison:
    """Build the full :class:`Comparison` payload.

    Pass either both pipelines (full A vs B report) or only Variant A
    (used when the YOLOv8 detector hasn't been promoted yet).
    """
    records = _load_test_records(seed=seed, limit=limit)
    catalog = load_active()

    report_a = evaluate_variant(variant_a_pipeline, "A", records, limit=limit)
    report_b = None
    if variant_b_pipeline is not None:
        report_b = evaluate_variant(variant_b_pipeline, "B", records, limit=limit)

    slices = _slice_analyses(report_a, report_b)

    return Comparison(
        generated_at=datetime.now(timezone.utc).isoformat(),
        catalog_id=catalog.catalog_id,
        test_split_size=len(records),
        seed=seed,
        variant_a=report_a,
        variant_b=report_b,
        model_versions={
            "classifier": _resolve_run_id("classifier"),
            "detector": _resolve_run_id("detector"),
            "identifier": _resolve_run_id("identifier"),
            "xgb_a": _resolve_run_id("xgb_a"),
            "xgb_b": _resolve_run_id("xgb_b"),
        },
        slices=slices,
    )


# ---------------------------------------------------------------------------
# Slice analyses
# ---------------------------------------------------------------------------


def _slice_analyses(a: VariantReport, b: Optional[VariantReport]) -> dict:
    """A small table summarising RMSE/MAE by damage type."""
    out: dict[str, Any] = {}
    out["headline"] = {
        "A": {
            "macro_f1": a.classification["macro_f1"],
            "rmse": a.regression["rmse"],
            "r2": a.regression["r2"],
            "mape_pct": a.regression["mape_pct"],
        },
    }
    if b is not None:
        out["headline"]["B"] = {
            "macro_f1": b.classification["macro_f1"],
            "rmse": b.regression["rmse"],
            "r2": b.regression["r2"],
            "mape_pct": b.regression["mape_pct"],
        }
        out["delta"] = {
            "macro_f1": b.classification["macro_f1"] - a.classification["macro_f1"],
            "rmse": b.regression["rmse"] - a.regression["rmse"],
            "r2": b.regression["r2"] - a.regression["r2"],
            "mape_pct": b.regression["mape_pct"] - a.regression["mape_pct"],
        }
    return out