Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import math | |
| import tempfile | |
| from pathlib import Path | |
| from typing import Any | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| import numpy as np | |
| import pandas as pd | |
| from reportlab.lib import colors | |
| from reportlab.lib.pagesizes import A4 | |
| from reportlab.lib.styles import getSampleStyleSheet | |
| from reportlab.lib.units import cm | |
| from reportlab.platypus import Image, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle | |
| def _safe_float(value: Any) -> float | None: | |
| try: | |
| number = float(value) | |
| except (TypeError, ValueError): | |
| return None | |
| return number if math.isfinite(number) else None | |
| def _prediction_columns(frame: pd.DataFrame) -> tuple[str, str] | None: | |
| for true_col, pred_col in ( | |
| ("y_true_vunit", "y_pred_vunit"), | |
| ("y_true", "y_pred"), | |
| ): | |
| if {true_col, pred_col}.issubset(frame.columns): | |
| return true_col, pred_col | |
| return None | |
| def compute_metrics(frame: pd.DataFrame) -> dict[str, float] | None: | |
| cols = _prediction_columns(frame) | |
| if cols is None or frame.empty: | |
| return None | |
| true_col, pred_col = cols | |
| work = frame[[true_col, pred_col]].apply(pd.to_numeric, errors="coerce").dropna() | |
| if work.empty: | |
| return None | |
| y_true = work[true_col].to_numpy(dtype=float) | |
| y_pred = work[pred_col].to_numpy(dtype=float) | |
| residual = y_pred - y_true | |
| ratio = np.divide(y_pred, y_true, out=np.full_like(y_pred, np.nan), where=y_true != 0) | |
| ratio = ratio[np.isfinite(ratio)] | |
| positive_mask = (y_true > 0) & (y_pred > 0) | |
| if positive_mask.any(): | |
| log_true = np.log(y_true[positive_mask]) | |
| log_pred = np.log(y_pred[positive_mask]) | |
| log_residual = log_pred - log_true | |
| log_ss_tot = float(np.sum((log_true - np.mean(log_true)) ** 2)) | |
| r2_log = float(1.0 - np.sum(log_residual**2) / log_ss_tot) if log_ss_tot > 0 else float("nan") | |
| else: | |
| r2_log = float("nan") | |
| mape_mask = y_true != 0 | |
| mape = float(np.mean(np.abs(residual[mape_mask] / y_true[mape_mask])) * 100.0) if mape_mask.any() else float("nan") | |
| out = { | |
| "n_obs": float(len(work)), | |
| "R2": r2_log, | |
| "RMSE": float(np.sqrt(np.mean(residual**2))), | |
| "MAE": float(np.mean(np.abs(residual))), | |
| "MAPE": mape, | |
| } | |
| if ratio.size: | |
| median_ratio = float(np.median(ratio)) | |
| mean_ratio = float(np.mean(ratio)) | |
| out.update( | |
| { | |
| "COD": float(np.mean(np.abs(ratio - median_ratio)) / median_ratio * 100.0) if median_ratio != 0 else float("nan"), | |
| "PRD": float(mean_ratio / (float(np.sum(y_pred)) / float(np.sum(y_true)))) if float(np.sum(y_true)) != 0 else float("nan"), | |
| "Mediana": median_ratio, | |
| } | |
| ) | |
| return out | |
| def split_prediction_frames(holdout_predictions: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]: | |
| if holdout_predictions.empty: | |
| return pd.DataFrame(), pd.DataFrame() | |
| if "split" not in holdout_predictions.columns: | |
| return pd.DataFrame(), holdout_predictions.copy() | |
| split = holdout_predictions["split"].astype("string").str.lower() | |
| train = holdout_predictions.loc[split.eq("train")].copy() | |
| test = holdout_predictions.loc[split.eq("test")].copy() | |
| return train, test | |
| def build_metric_summary( | |
| holdout_predictions: pd.DataFrame, | |
| metadata: dict[str, Any], | |
| ) -> dict[str, dict[str, float] | None]: | |
| train_frame, test_frame = split_prediction_frames(holdout_predictions) | |
| train_metrics = compute_metrics(train_frame) | |
| test_metrics = compute_metrics(test_frame) | |
| if test_metrics is None: | |
| metadata_holdout = metadata.get("holdout_metrics") | |
| if isinstance(metadata_holdout, dict): | |
| test_metrics = { | |
| key: float(value) | |
| for key, value in metadata_holdout.items() | |
| if key != "R2" and _safe_float(value) is not None | |
| } | |
| return {"train": train_metrics, "test": test_metrics} | |
| def _fmt(value: Any, digits: int = 4) -> str: | |
| number = _safe_float(value) | |
| if number is None: | |
| return "n/d" | |
| return f"{number:.{digits}f}" | |
| def make_scatter_plot(frame: pd.DataFrame, title: str, output_path: Path) -> Path: | |
| cols = _prediction_columns(frame) | |
| fig, ax = plt.subplots(figsize=(5.6, 4.0), dpi=160) | |
| if cols is None or frame.empty: | |
| ax.axis("off") | |
| ax.text( | |
| 0.5, | |
| 0.5, | |
| "Predicoes nao disponiveis\nnos artefatos exportados", | |
| ha="center", | |
| va="center", | |
| fontsize=11, | |
| ) | |
| else: | |
| true_col, pred_col = cols | |
| work = frame[[true_col, pred_col]].apply(pd.to_numeric, errors="coerce").dropna() | |
| work = work.loc[(work[true_col] > 0) & (work[pred_col] > 0)].copy() | |
| if work.empty: | |
| ax.axis("off") | |
| ax.text(0.5, 0.5, "Sem observacoes validas", ha="center", va="center", fontsize=11) | |
| else: | |
| x = work[true_col].to_numpy(dtype=float) | |
| y = work[pred_col].to_numpy(dtype=float) | |
| lim_min = float(min(np.min(x), np.min(y))) | |
| lim_max = float(max(np.max(x), np.max(y))) | |
| ax.scatter(x, y, s=14, alpha=0.45, color="#2563EB", edgecolors="none") | |
| ax.plot([lim_min, lim_max], [lim_min, lim_max], color="#DC2626", linewidth=1.2) | |
| ax.set_xscale("log") | |
| ax.set_yscale("log") | |
| ax.set_xlabel("Valor observado (escala log)") | |
| ax.set_ylabel("Valor inferido (escala log)") | |
| ax.grid(alpha=0.25, which="both") | |
| ax.set_title(title, fontsize=11) | |
| fig.tight_layout() | |
| fig.savefig(output_path, bbox_inches="tight") | |
| plt.close(fig) | |
| return output_path | |
| def _table(data: list[list[Any]], widths: list[float] | None = None) -> Table: | |
| table = Table(data, colWidths=widths) | |
| table.setStyle( | |
| TableStyle( | |
| [ | |
| ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1F2937")), | |
| ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), | |
| ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), | |
| ("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#D1D5DB")), | |
| ("VALIGN", (0, 0), (-1, -1), "TOP"), | |
| ("FONTNAME", (0, 1), (-1, -1), "Helvetica"), | |
| ("FONTSIZE", (0, 0), (-1, -1), 8), | |
| ("BACKGROUND", (0, 1), (-1, -1), colors.HexColor("#F9FAFB")), | |
| ] | |
| ) | |
| ) | |
| return table | |
| def build_pdf_report( | |
| *, | |
| model_label: str, | |
| model_file: str, | |
| user_inputs: dict[str, Any], | |
| prepared_values: dict[str, Any], | |
| source_map: dict[str, str], | |
| prediction: dict[str, Any], | |
| metrics: dict[str, dict[str, float] | None], | |
| train_predictions: pd.DataFrame, | |
| test_predictions: pd.DataFrame, | |
| limitations: list[str], | |
| ) -> str: | |
| report_dir = Path(tempfile.mkdtemp(prefix="avm_report_")) | |
| train_plot = make_scatter_plot(train_predictions, "Treino: observado x inferido", report_dir / "scatter_train.png") | |
| test_plot = make_scatter_plot(test_predictions, "Teste: observado x inferido", report_dir / "scatter_test.png") | |
| pdf_path = report_dir / "relatorio_inferencia_avm.pdf" | |
| styles = getSampleStyleSheet() | |
| doc = SimpleDocTemplate( | |
| str(pdf_path), | |
| pagesize=A4, | |
| rightMargin=1.4 * cm, | |
| leftMargin=1.4 * cm, | |
| topMargin=1.2 * cm, | |
| bottomMargin=1.2 * cm, | |
| ) | |
| story: list[Any] = [ | |
| Paragraph("Relatorio de Inferencia Territorial", styles["Title"]), | |
| Spacer(1, 0.3 * cm), | |
| Paragraph(f"Modelo utilizado: <b>{model_label}</b> ({model_file})", styles["BodyText"]), | |
| Spacer(1, 0.25 * cm), | |
| ] | |
| story.append(Paragraph("Dados informados pelo usuario", styles["Heading2"])) | |
| user_rows = [["Campo", "Valor"]] + [[str(key), str(value)] for key, value in user_inputs.items()] | |
| story.append(_table(user_rows, [5.0 * cm, 11.0 * cm])) | |
| story.append(Spacer(1, 0.25 * cm)) | |
| story.append(Paragraph("Predicao", styles["Heading2"])) | |
| pred_rows = [ | |
| ["Indicador", "Valor"], | |
| ["Valor unitario estimado", f"R$ {prediction['valor_unitario']:,.2f} / m2"], | |
| ["Valor total estimado", f"R$ {prediction['valor_total']:,.2f}"], | |
| ["Modo da saida do modelo", str(prediction["target_mode"])], | |
| ] | |
| story.append(_table(pred_rows, [6.0 * cm, 10.0 * cm])) | |
| story.append(Spacer(1, 0.25 * cm)) | |
| story.append(Paragraph("Variaveis efetivamente usadas na inferencia", styles["Heading2"])) | |
| feature_rows = [["Variavel", "Valor", "Fonte"]] | |
| for key in sorted(prepared_values): | |
| if key.startswith("_"): | |
| continue | |
| feature_rows.append([key, str(prepared_values.get(key)), str(source_map.get(key, "calculado/derivado"))]) | |
| story.append(_table(feature_rows, [5.0 * cm, 5.2 * cm, 5.8 * cm])) | |
| story.append(Spacer(1, 0.25 * cm)) | |
| story.append(Paragraph("Metricas do modelo", styles["Heading2"])) | |
| metrics_rows = [["Conjunto", "n", "R2 (log)", "RMSE", "MAE", "MAPE", "COD", "PRD", "Mediana"]] | |
| for split_name, metric in (("Treino", metrics.get("train")), ("Teste", metrics.get("test"))): | |
| metrics_rows.append( | |
| [ | |
| split_name, | |
| _fmt(metric.get("n_obs") if metric else None, 0), | |
| _fmt(metric.get("R2") if metric else None), | |
| _fmt(metric.get("RMSE") if metric else None, 2), | |
| _fmt(metric.get("MAE") if metric else None, 2), | |
| _fmt(metric.get("MAPE") if metric else None, 2), | |
| _fmt(metric.get("COD") if metric else None, 2), | |
| _fmt(metric.get("PRD") if metric else None, 4), | |
| _fmt(metric.get("Mediana") if metric else None, 4), | |
| ] | |
| ) | |
| story.append(_table(metrics_rows)) | |
| story.append(Spacer(1, 0.25 * cm)) | |
| story.append(Paragraph("Dispersao observado x inferido", styles["Heading2"])) | |
| story.append(Image(str(train_plot), width=7.8 * cm, height=5.6 * cm)) | |
| story.append(Spacer(1, 0.15 * cm)) | |
| story.append(Image(str(test_plot), width=7.8 * cm, height=5.6 * cm)) | |
| story.append(Spacer(1, 0.25 * cm)) | |
| story.append(Paragraph("Limitacoes e observacoes", styles["Heading2"])) | |
| for item in limitations: | |
| story.append(Paragraph(f"- {item}", styles["BodyText"])) | |
| doc.build(story) | |
| return str(pdf_path) | |