File size: 9,804 Bytes
fef29bc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Demonstrate the CV-to-ML integration: damage_score effect on final CHF price.

This script explicitly quantifies how the Computer Vision damage score drives
the final price recommendation across multiple vehicle types and price segments.
It produces tables and a figure showing the transparent damage-to-price pipeline.

No external API calls are made. Only the deterministic damage_score formula
and CHF calibration are used.

Usage:
    python scripts/run_damage_sensitivity.py
"""

from __future__ import annotations

import sys
from pathlib import Path

import numpy as np
import pandas as pd

PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.append(str(PROJECT_ROOT))

from app.damage_model import (
    DAMAGE_WEIGHTS,
    MAX_DAMAGE_SCORE,
    calculate_adjusted_price,
    calculate_damage_score,
)
from app.utils import EUR_TO_CHF_RATE, SWISS_MARKET_FACTOR, eur_to_chf, format_chf

REPORT_PATH = PROJECT_ROOT / "reports/damage_sensitivity_report.md"
FIGURE_PATH = PROJECT_ROOT / "reports/damage_sensitivity_curve.png"

REPRESENTATIVE_VEHICLES = [
    {"label": "VW Golf (mid-range)",       "base_eur": 15_000},
    {"label": "BMW 3 Series (premium)",    "base_eur": 28_000},
    {"label": "Tesla Model 3 (electric)",  "base_eur": 34_000},
    {"label": "Toyota Corolla (hybrid)",   "base_eur": 20_000},
    {"label": "Porsche 911 (luxury)",      "base_eur": 118_000},
    {"label": "Ford Transit (commercial)", "base_eur": 9_200},
]

DAMAGE_SCORE_STEPS = [0.00, 0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.35]

DAMAGE_LABEL_EXAMPLES = [
    ("No damage",                         []),
    ("Minor scratch",                     ["scratch"]),
    ("Scratch + dent",                    ["scratch", "dent"]),
    ("Dent + crack",                      ["dent", "crack"]),
    ("Lamp broken + glass shatter",       ["lamp broken", "glass shatter"]),
    ("Crushed + lamp broken + crack",     ["crushed", "lamp broken", "crack"]),
    ("Multiple severe (capped at 0.35)",  ["crushed", "glass shatter", "lamp broken", "crack", "dent"]),
]


def damage_labels_to_score(labels: list[str]) -> float:
    from app.damage_model import DetectedDamage, normalize_damage_label
    detections = [
        DetectedDamage(label=normalize_damage_label(lbl), confidence=1.0)
        for lbl in labels
        if DAMAGE_WEIGHTS.get(normalize_damage_label(lbl), 0.05) > 0
    ]
    return calculate_damage_score(detections)


def build_per_vehicle_sensitivity_table() -> pd.DataFrame:
    """For each representative vehicle, show CHF price at each damage score level."""
    rows = []
    for vehicle in REPRESENTATIVE_VEHICLES:
        base_eur = vehicle["base_eur"]
        base_chf = eur_to_chf(base_eur) or base_eur
        for score in DAMAGE_SCORE_STEPS:
            discount_eur, adjusted_eur = calculate_adjusted_price(base_eur, score)
            adjusted_chf = eur_to_chf(adjusted_eur) or adjusted_eur
            discount_chf = base_chf - adjusted_chf
            rows.append({
                "vehicle": vehicle["label"],
                "base_chf": round(base_chf),
                "damage_score": score,
                "discount_chf": round(discount_chf),
                "adjusted_chf": round(adjusted_chf),
                "price_reduction_pct": round(score * 100, 1),
            })
    return pd.DataFrame(rows)


def build_label_to_score_table() -> pd.DataFrame:
    """Show how CV-detected damage labels map to damage scores and CHF discounts."""
    rows = []
    for description, labels in DAMAGE_LABEL_EXAMPLES:
        score = damage_labels_to_score(labels)
        for vehicle in REPRESENTATIVE_VEHICLES[:3]:
            base_eur = vehicle["base_eur"]
            discount_eur, adjusted_eur = calculate_adjusted_price(base_eur, score)
            adjusted_chf = eur_to_chf(adjusted_eur) or adjusted_eur
            base_chf = eur_to_chf(base_eur) or base_eur
            discount_chf = base_chf - adjusted_chf
            rows.append({
                "damage_description": description,
                "damage_labels": ", ".join(labels) or "none",
                "damage_score": score,
                "vehicle": vehicle["label"],
                "base_chf": round(base_chf),
                "discount_chf": round(discount_chf),
                "adjusted_chf": round(adjusted_chf),
            })
    return pd.DataFrame(rows)


def save_sensitivity_figure(df: pd.DataFrame) -> None:
    try:
        import matplotlib.pyplot as plt
        fig, ax = plt.subplots(figsize=(8, 5))
        for vehicle in REPRESENTATIVE_VEHICLES:
            subset = df[df["vehicle"] == vehicle["label"]]
            ax.plot(
                subset["damage_score"],
                subset["adjusted_chf"],
                marker="o",
                label=vehicle["label"],
            )
        ax.set_xlabel("Damage Score (0 = no damage, 0.35 = max)")
        ax.set_ylabel("Adjusted Listing Price (CHF)")
        ax.set_title("CV Damage Score β†’ Final CHF Listing Price")
        ax.legend(fontsize=8, loc="upper right")
        ax.grid(True, alpha=0.3)
        fig.tight_layout()
        fig.savefig(FIGURE_PATH, dpi=120)
        plt.close(fig)
        print(f"Saved figure: {FIGURE_PATH}")
    except ImportError:
        pass


def write_report(
    per_vehicle_df: pd.DataFrame,
    label_score_df: pd.DataFrame,
) -> None:
    REPORT_PATH.parent.mkdir(parents=True, exist_ok=True)

    lines = [
        "# CV-to-ML Integration: Damage Sensitivity Analysis",
        "",
        "This report explicitly demonstrates how the Computer Vision block drives",
        "the final CHF price recommendation in the integrated pipeline.",
        "",
        "## Integration Pipeline",
        "",
        "```",
        "Vehicle photos",
        "  β†’ OpenAI Vision / local CV model",
        "  β†’ damage labels (e.g. 'scratch', 'dent', 'lamp broken')",
        "  β†’ damage_score = Ξ£ weight_i Γ— confidence_i  (capped at 0.35)",
        "  β†’ CHF discount = base_CHF Γ— damage_score",
        "  β†’ adjusted_CHF = base_CHF βˆ’ CHF discount",
        "```",
        "",
        f"Calibration constants: EUR_TO_CHF_RATE = {EUR_TO_CHF_RATE}, "
        f"SWISS_MARKET_FACTOR = {SWISS_MARKET_FACTOR}",
        "",
        "## Damage Label Weights",
        "",
        "| Damage label | Weight | Effect at CHF 20,000 base |",
        "|---|---:|---:|",
    ]
    for label, weight in sorted(DAMAGE_WEIGHTS.items(), key=lambda x: -x[1]):
        if weight > 0:
            sample_base_chf = eur_to_chf(20_000) or 20_000
            effect_chf = round(sample_base_chf * weight)
            lines.append(f"| {label} | {weight:.2f} | βˆ’CHF {effect_chf:,} |")

    lines.extend([
        "",
        f"Maximum damage score cap: **{MAX_DAMAGE_SCORE}** (prevents unrealistic price collapse)",
        "",
        "## Damage Labels β†’ Score β†’ Price: Reference Table",
        "",
        "| Damage description | Labels | Score | VW Golf base CHF | Discount CHF | Adjusted CHF |",
        "|---|---|---:|---:|---:|---:|",
    ])
    vw_rows = label_score_df[label_score_df["vehicle"].str.contains("VW Golf")]
    for _, row in vw_rows.iterrows():
        lines.append(
            f"| {row['damage_description']} | {row['damage_labels']} | "
            f"{row['damage_score']:.3f} | {row['base_chf']:,} | "
            f"{row['discount_chf']:,} | {row['adjusted_chf']:,} |"
        )

    lines.extend([
        "",
        "## Price Sensitivity Across Vehicle Types",
        "",
        "| Vehicle | Base CHF | Damage 0% | Damage 5% | Damage 10% | Damage 20% | Damage 35% (max) |",
        "|---|---:|---:|---:|---:|---:|---:|",
    ])
    for vehicle in REPRESENTATIVE_VEHICLES:
        subset = per_vehicle_df[per_vehicle_df["vehicle"] == vehicle["label"]]
        vals = {
            row["damage_score"]: row["adjusted_chf"]
            for _, row in subset.iterrows()
        }
        base_chf = subset.iloc[0]["base_chf"]
        lines.append(
            f"| {vehicle['label']} | {base_chf:,} | "
            f"{vals.get(0.00, 'β€”'):,} | {vals.get(0.05, 'β€”'):,} | "
            f"{vals.get(0.10, 'β€”'):,} | {vals.get(0.20, 'β€”'):,} | "
            f"{vals.get(0.35, 'β€”'):,} |"
        )

    lines.extend([
        "",
        f"Figure: `{FIGURE_PATH.relative_to(PROJECT_ROOT)}`",
        "",
        "## Key Findings",
        "",
        "- Damage score = 0.00 (no CV damage detected): no price discount applied",
        f"- Damage score = 0.05 (minor scratch): ~5% price reduction",
        f"- Damage score = 0.20 (moderate damage): ~20% price reduction",
        f"- Damage score = 0.35 (severe/multiple, cap): ~35% price reduction",
        "- The cap at 0.35 prevents a full-price collapse for multiple simultaneous detections",
        "- Price reduction is proportional in CHF, so absolute discounts are higher for expensive vehicles",
        "",
        "## Integration Evidence",
        "",
        "The damage_score produced by the CV block directly changes the NLP block inputs",
        "and the final CHF listing price recommendation. This is the primary integration",
        "channel between Computer Vision and ML Numeric Data / NLP in this application.",
        "The scoring formula is fully transparent and deterministic, allowing users to",
        "understand exactly how a detected 'dent' or 'lamp broken' reduces their asking price.",
    ])
    REPORT_PATH.write_text("\n".join(lines), encoding="utf-8")


def main() -> None:
    print("Computing damage sensitivity tables...")
    per_vehicle_df = build_per_vehicle_sensitivity_table()
    label_score_df = build_label_to_score_table()

    save_sensitivity_figure(per_vehicle_df)
    write_report(per_vehicle_df, label_score_df)

    print(f"Wrote {REPORT_PATH}")


if __name__ == "__main__":
    main()