Occasion-Scanner / scripts /run_damage_sensitivity.py
ganemmah's picture
Sync project documentation and evaluation artifacts
fef29bc verified
Raw
History Blame Contribute Delete
9.8 kB
"""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()