| 220 | |
| 221 | |
| 222 | def plot_scatter_per_doc(per_parser_docs: Dict[str, List[Tuple[str, int, float]]], viz_dir: Path) -> None: |
| 223 | for parser, docs in per_parser_docs.items(): |
| 224 | if not docs: |
| 225 | continue |
| 226 | xs = np.array([d[1] for d in docs], dtype=float) # pages |
| 227 | ys = np.array([d[2] for d in docs], dtype=float) # total time (sec) |
| 228 | if xs.size == 0: |
| 229 | continue |
| 230 | plt.figure(figsize=(8, 5)) |
| 231 | plt.scatter(xs, ys, s=18, alpha=0.7, label="documents") |
| 232 | |
| 233 | # Linear fit if we have 2+ points and non-NaN values |
| 234 | if xs.size >= 2 and np.isfinite(xs).all() and np.isfinite(ys).all(): |
| 235 | try: |
| 236 | coeffs = np.polyfit(xs, ys, deg=1) |
| 237 | slope, intercept = coeffs[0], coeffs[1] |
| 238 | x_line = np.linspace(xs.min(), xs.max(), 100) |
| 239 | y_line = slope * x_line + intercept |
| 240 | # R^2 for fit quality |
| 241 | y_pred = slope * xs + intercept |
| 242 | ss_res = np.sum((ys - y_pred) ** 2) |
| 243 | ss_tot = np.sum((ys - np.mean(ys)) ** 2) |
| 244 | r2 = 1 - ss_res / ss_tot if ss_tot > 0 else np.nan |
| 245 | plt.plot(x_line, y_line, color="orange", label=f"fit: y={slope:.4f}x+{intercept:.3f} (R²={r2:.3f})") |
| 246 | except Exception: |
| 247 | pass |
| 248 | |
| 249 | plt.title(f"Total time vs pages — {parser} (n={xs.size})") |
| 250 | plt.xlabel("Pages per document") |
| 251 | plt.ylabel("Total seconds per document") |
| 252 | plt.grid(True, alpha=0.3) |
| 253 | plt.legend() |
| 254 | out = viz_dir / f"scatter_pages_vs_time_{safe_name(parser)}.png" |
| 255 | plt.tight_layout() |
| 256 | plt.savefig(out, dpi=150) |
| 257 | plt.close() |
| 258 | |
| 259 | |
| 260 | def plot_hex_pairs(per_parser_rows: Dict[str, List[PageRow]], viz_dir: Path) -> None: |