data-detective-toolkit 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,730 @@
1
+ from html import escape
2
+ from pathlib import Path
3
+
4
+
5
+ def _render_kv_table(d: dict, empty_message="No data.") -> str:
6
+ if not d:
7
+ return f'<p class="empty">{empty_message}</p>'
8
+
9
+ rows = "".join(
10
+ f"<tr><td>{escape(str(k))}</td><td>{escape(str(v))}</td></tr>"
11
+ for k, v in d.items()
12
+ )
13
+ return f"<table><tbody>{rows}</tbody></table>"
14
+
15
+
16
+ def _render_list(items: list, empty_message="None found.") -> str:
17
+ if not items:
18
+ return f'<p class="empty">{empty_message}</p>'
19
+
20
+ lis = "".join(f"<li>{escape(str(item))}</li>" for item in items)
21
+ return f"<ul>{lis}</ul>"
22
+
23
+
24
+ def _render_pairs(pairs: list, empty_message="None found.") -> str:
25
+ """Renders list of tuples like (col_a, col_b) or (col_a, col_b, score)."""
26
+ if not pairs:
27
+ return f'<p class="empty">{empty_message}</p>'
28
+
29
+ lis = []
30
+ for pair in pairs:
31
+ if len(pair) == 3:
32
+ a, b, score = pair
33
+ lis.append(
34
+ f"<li><strong>{escape(str(a))}</strong> ↔ <strong>{escape(str(b))}</strong> "
35
+ f"(score: {score})</li>"
36
+ )
37
+ else:
38
+ a, b = pair
39
+ lis.append(f"<li><strong>{escape(str(a))}</strong> ↔ <strong>{escape(str(b))}</strong></li>")
40
+
41
+ return f"<ul>{''.join(lis)}</ul>"
42
+
43
+
44
+ def _render_nested_table(d: dict, empty_message="No data.") -> str:
45
+ if not d:
46
+ return f'<p class="empty">{empty_message}</p>'
47
+
48
+ rows = []
49
+ for outer_key, inner_value in d.items():
50
+ if isinstance(inner_value, dict):
51
+ nested_rows = "".join(
52
+ f"<tr><td>{escape(str(k))}</td><td>{escape(str(v))}</td></tr>"
53
+ for k, v in inner_value.items()
54
+ )
55
+ nested_html = f'<table class="nested"><tbody>{nested_rows}</tbody></table>'
56
+ else:
57
+ nested_html = escape(str(inner_value))
58
+ rows.append(f"<tr><td>{escape(str(outer_key))}</td><td>{nested_html}</td></tr>")
59
+
60
+ return f'<table class="nested-summary"><tbody>{"".join(rows)}</tbody></table>'
61
+
62
+
63
+ def _render_insights(insights: list) -> str:
64
+ if not insights:
65
+ return '<p class="empty">No notable insights. Data looks clean. ✅</p>'
66
+
67
+ items = "".join(f'<li class="insight">{escape(item)}</li>' for item in insights)
68
+ return f'<ul class="insights-list">{items}</ul>'
69
+
70
+
71
+ def _color_for_correlation(value: float) -> str:
72
+ """Blue for positive correlation, pink/red for negative, scaled by strength."""
73
+ v = max(-1.0, min(1.0, value))
74
+ if v >= 0:
75
+ intensity = round(255 - v * 140)
76
+ return f"rgb({intensity - 60}, {intensity - 20}, 255)"
77
+ intensity = round(255 + v * 140)
78
+ return f"rgb(255, {intensity - 60}, {intensity - 20})"
79
+
80
+
81
+ def _render_correlation_heatmap(matrix: dict, empty_message="Not enough numeric columns for a heatmap.") -> str:
82
+ cols = list(matrix.keys())
83
+ if len(cols) < 2:
84
+ return f'<p class="empty">{empty_message}</p>'
85
+
86
+ header_cells = "".join(f"<th>{escape(str(c))}</th>" for c in cols)
87
+ body_rows = []
88
+ for row_key in cols:
89
+ cells = []
90
+ for col_key in cols:
91
+ value = matrix[row_key].get(col_key, 0)
92
+ color = _color_for_correlation(value)
93
+ cells.append(f'<td><div class="heatmap-cell" style="background:{color}">{value:.2f}</div></td>')
94
+ body_rows.append(f"<tr><th>{escape(str(row_key))}</th>{''.join(cells)}</tr>")
95
+
96
+ return (
97
+ '<div class="heatmap-wrap"><table class="heatmap-table"><thead>'
98
+ f"<tr><th></th>{header_cells}</tr></thead><tbody>{''.join(body_rows)}</tbody></table></div>"
99
+ )
100
+
101
+
102
+ def _render_explorer_histogram(hist: dict, empty_message="No histogram data for this column.") -> str:
103
+ if not hist:
104
+ return f'<p class="empty">{empty_message}</p>'
105
+
106
+ bin_edges = hist.get("bin_edges", [])
107
+ counts = hist.get("counts", [])
108
+ max_count = max(counts) if counts else 1
109
+
110
+ bars = []
111
+ for i, c in enumerate(counts):
112
+ height = max((c / max_count) * 100, 2)
113
+ edge = bin_edges[i] if i < len(bin_edges) else ""
114
+ next_edge = bin_edges[i + 1] if i + 1 < len(bin_edges) else ""
115
+ title = escape(f"{edge} to {next_edge}: {c} rows")
116
+ bars.append(
117
+ f'<div class="explorer-hist-bar-wrap" title="{title}">'
118
+ f'<div class="explorer-hist-count">{c}</div>'
119
+ f'<div class="hist-bar" style="height:{height:.1f}%"></div>'
120
+ f'<div class="explorer-hist-edge">{escape(str(edge))}</div>'
121
+ "</div>"
122
+ )
123
+
124
+ return f'<div class="explorer-hist-bars">{"".join(bars)}</div>'
125
+
126
+
127
+ def _render_explorer_stats(col: str, box: dict, report: dict) -> str:
128
+ if not box:
129
+ return ""
130
+
131
+ shape = report.get("distribution_shape", {}).get(col)
132
+ outliers_mad = report.get("outliers_mad", {}).get(col)
133
+ outliers_iqr = report.get("outliers_iqr", {}).get(col)
134
+ missing_pct = report.get("missing_percentage", {}).get(col)
135
+
136
+ stats = [
137
+ ("Min", box["min"]),
138
+ ("Q1", box["q1"]),
139
+ ("Median", box["median"]),
140
+ ("Q3", box["q3"]),
141
+ ("Max", box["max"]),
142
+ ("Outliers beyond whiskers", len(box["outliers"])),
143
+ ]
144
+ if outliers_mad is not None:
145
+ stats.append(("Outliers (MAD)", outliers_mad))
146
+ if outliers_iqr is not None:
147
+ stats.append(("Outliers (IQR)", outliers_iqr))
148
+ if shape:
149
+ stats.append(("Skewness", shape["skewness"]))
150
+ stats.append(("Kurtosis", shape["kurtosis"]))
151
+ if missing_pct is not None:
152
+ stats.append(("Missing", f"{missing_pct}%"))
153
+
154
+ chips = "".join(
155
+ f'<div class="stat-chip"><span class="stat-chip-value">{escape(str(value))}</span>'
156
+ f'<span class="stat-chip-label">{escape(str(label))}</span></div>'
157
+ for label, value in stats
158
+ )
159
+ return f'<div class="explorer-stats">{chips}</div>'
160
+
161
+
162
+ def _render_column_explorer(report: dict) -> str:
163
+ """
164
+ Interactive per-column drill-down: a dropdown switches between hidden
165
+ panels (one per column) via a tiny inline script, so this works in a
166
+ standalone HTML file with no server. High-cardinality (ID-like) and
167
+ constant columns are excluded since a chart of them isn't meaningful.
168
+ """
169
+ histograms = report.get("histogram_data", {})
170
+ boxplots = report.get("boxplot_stats", {})
171
+ excluded = set(report.get("high_cardinality_columns", [])) | set(report.get("constant_columns", []))
172
+
173
+ columns = []
174
+ for col in list(histograms.keys()) + list(boxplots.keys()):
175
+ if col not in excluded and col not in columns:
176
+ columns.append(col)
177
+
178
+ note = (
179
+ '<p class="explorer-note">ID-like and constant columns are left out here since a chart of '
180
+ "them isn't meaningful. See the full technical report below for those.</p>"
181
+ )
182
+
183
+ if not columns:
184
+ if not histograms and not boxplots:
185
+ return note + '<p class="empty">No numeric columns to chart.</p>'
186
+ return note + (
187
+ '<p class="empty">No columns suitable for detailed charting: the remaining numeric '
188
+ "columns look like IDs or are constant.</p>"
189
+ )
190
+
191
+ options = "".join(f'<option value="{escape(col)}">{escape(col)}</option>' for col in columns)
192
+
193
+ panels = []
194
+ for i, col in enumerate(columns):
195
+ hidden_attr = "" if i == 0 else " hidden"
196
+ box = boxplots.get(col)
197
+ boxplot_html = _boxplot_svg(box) if box else '<p class="empty">No boxplot data.</p>'
198
+ panels.append(
199
+ f'<div class="explorer-panel"{hidden_attr} data-column="{escape(col)}">'
200
+ '<div class="grid-2">'
201
+ f'<div class="explorer-block"><h4>Histogram</h4>{_render_explorer_histogram(histograms.get(col))}</div>'
202
+ f'<div class="explorer-block"><h4>Boxplot</h4>{boxplot_html}</div>'
203
+ "</div>"
204
+ f"{_render_explorer_stats(col, box, report)}"
205
+ "</div>"
206
+ )
207
+
208
+ controls = (
209
+ '<div class="explorer-controls"><label for="column-select">Column</label>'
210
+ f'<select id="column-select" onchange="ddShowColumn(this.value)">{options}</select></div>'
211
+ )
212
+
213
+ script = (
214
+ "<script>\n"
215
+ "function ddShowColumn(col) {\n"
216
+ " document.querySelectorAll('.explorer-panel').forEach(function (el) {\n"
217
+ " el.hidden = el.getAttribute('data-column') !== col;\n"
218
+ " });\n"
219
+ "}\n"
220
+ "</script>"
221
+ )
222
+
223
+ return note + controls + f'<div id="explorer-blocks">{"".join(panels)}</div>' + script
224
+
225
+
226
+ def _boxplot_svg(box: dict) -> str:
227
+ width, height, padding = 300, 46, 14
228
+ values = [box["min"], box["max"], box["whisker_low"], box["whisker_high"], *box["outliers"]]
229
+ domain_min, domain_max = min(values), max(values)
230
+ span = (domain_max - domain_min) or 1
231
+
232
+ def scale(v):
233
+ return padding + ((v - domain_min) / span) * (width - 2 * padding)
234
+
235
+ def svg_line(css_class, x1, y1, x2, y2):
236
+ return f'<line class="{css_class}" x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" />'
237
+
238
+ mid_y = height / 2
239
+ box_top = mid_y - 13
240
+ box_height = 26
241
+ whisker_low_x = scale(box["whisker_low"])
242
+ whisker_high_x = scale(box["whisker_high"])
243
+
244
+ whisker_line = svg_line("box-whisker", whisker_low_x, mid_y, whisker_high_x, mid_y)
245
+ cap_low = svg_line("box-whisker", whisker_low_x, mid_y - 6, whisker_low_x, mid_y + 6)
246
+ cap_high = svg_line("box-whisker", whisker_high_x, mid_y - 6, whisker_high_x, mid_y + 6)
247
+ box_x = scale(box["q1"])
248
+ box_width = max(scale(box["q3"]) - scale(box["q1"]), 1)
249
+ rect = f'<rect class="box-rect" x="{box_x}" y="{box_top}" width="{box_width}" height="{box_height}" rx="3" />'
250
+ median_x = scale(box["median"])
251
+ median = svg_line("box-median", median_x, box_top, median_x, box_top + box_height)
252
+ outliers = "".join(
253
+ f'<circle class="box-outlier" cx="{scale(v)}" cy="{mid_y}" r="3" />' for v in box["outliers"]
254
+ )
255
+
256
+ return (
257
+ f'<svg class="box-plot-svg" viewBox="0 0 {width} {height}" preserveAspectRatio="none">'
258
+ f"{whisker_line}{cap_low}{cap_high}{rect}{median}{outliers}</svg>"
259
+ )
260
+
261
+
262
+ def generate_html_report(report: dict, output_path="report.html"):
263
+ shape = report.get("shape", {})
264
+ rows = shape.get("rows", "N/A")
265
+ cols = shape.get("columns", "N/A")
266
+ duplicates = report.get("duplicates", 0)
267
+
268
+ html = f"""<!DOCTYPE html>
269
+ <html lang="en">
270
+ <head>
271
+ <meta charset="UTF-8">
272
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
273
+ <title>Data Detective Report</title>
274
+ <style>
275
+ :root {{
276
+ --bg: #f5f5f7;
277
+ --card-bg: #ffffff;
278
+ --text: #1d1d1f;
279
+ --muted: #6e6e73;
280
+ --accent: #0071e3;
281
+ --border: #e5e5e7;
282
+ --danger: #d70015;
283
+ --warning: #c9720b;
284
+ }}
285
+
286
+ * {{ box-sizing: border-box; }}
287
+
288
+ body {{
289
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text",
290
+ "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
291
+ background: var(--bg);
292
+ color: var(--text);
293
+ margin: 0;
294
+ padding: 48px 24px;
295
+ line-height: 1.5;
296
+ -webkit-font-smoothing: antialiased;
297
+ }}
298
+
299
+ .container {{
300
+ max-width: 920px;
301
+ margin: 0 auto;
302
+ }}
303
+
304
+ header {{
305
+ margin-bottom: 32px;
306
+ }}
307
+
308
+ header h1 {{
309
+ font-size: 32px;
310
+ font-weight: 700;
311
+ letter-spacing: -0.02em;
312
+ margin: 0 0 4px 0;
313
+ }}
314
+
315
+ header p {{
316
+ color: var(--muted);
317
+ margin: 0;
318
+ font-size: 15px;
319
+ }}
320
+
321
+ .stat-grid {{
322
+ display: grid;
323
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
324
+ gap: 16px;
325
+ margin-bottom: 32px;
326
+ }}
327
+
328
+ .stat-card {{
329
+ background: var(--card-bg);
330
+ border: 1px solid var(--border);
331
+ border-radius: 14px;
332
+ padding: 20px;
333
+ text-align: center;
334
+ }}
335
+
336
+ .stat-card .value {{
337
+ font-size: 28px;
338
+ font-weight: 700;
339
+ }}
340
+
341
+ .stat-card .label {{
342
+ font-size: 13px;
343
+ color: var(--muted);
344
+ margin-top: 4px;
345
+ }}
346
+
347
+ .box {{
348
+ background: var(--card-bg);
349
+ border: 1px solid var(--border);
350
+ border-radius: 14px;
351
+ padding: 24px;
352
+ margin-bottom: 20px;
353
+ /* Grid items default to min-width: auto, which lets wide content
354
+ (e.g. the correlation heatmap table) force the whole grid, and
355
+ the page, to overflow horizontally instead of scrolling here. */
356
+ min-width: 0;
357
+ }}
358
+
359
+ .box h2 {{
360
+ font-size: 17px;
361
+ font-weight: 600;
362
+ margin: 0 0 14px 0;
363
+ display: flex;
364
+ align-items: center;
365
+ gap: 8px;
366
+ }}
367
+
368
+ table {{
369
+ width: 100%;
370
+ border-collapse: collapse;
371
+ font-size: 14px;
372
+ }}
373
+
374
+ td {{
375
+ padding: 9px 4px;
376
+ border-bottom: 1px solid var(--border);
377
+ }}
378
+
379
+ td:first-child {{
380
+ color: var(--muted);
381
+ width: 60%;
382
+ }}
383
+
384
+ td:last-child {{
385
+ font-weight: 500;
386
+ text-align: right;
387
+ }}
388
+
389
+ .nested-summary td:last-child,
390
+ .nested td:last-child {{
391
+ text-align: left;
392
+ }}
393
+
394
+ .nested {{
395
+ width: 100%;
396
+ }}
397
+
398
+ .nested td {{
399
+ padding: 4px 0;
400
+ border-bottom: none;
401
+ }}
402
+
403
+ tr:last-child td {{
404
+ border-bottom: none;
405
+ }}
406
+
407
+ ul {{
408
+ margin: 0;
409
+ padding-left: 20px;
410
+ font-size: 14px;
411
+ }}
412
+
413
+ ul li {{
414
+ margin-bottom: 6px;
415
+ }}
416
+
417
+ .insights-list {{
418
+ list-style: none;
419
+ padding-left: 0;
420
+ }}
421
+
422
+ .insights-list li {{
423
+ background: #f9f9fb;
424
+ border-radius: 10px;
425
+ padding: 10px 14px;
426
+ margin-bottom: 8px;
427
+ font-size: 14px;
428
+ }}
429
+
430
+ .empty {{
431
+ color: var(--muted);
432
+ font-size: 14px;
433
+ font-style: italic;
434
+ margin: 0;
435
+ }}
436
+
437
+ .grid-2 {{
438
+ display: grid;
439
+ grid-template-columns: 1fr 1fr;
440
+ gap: 20px;
441
+ }}
442
+
443
+ @media (max-width: 640px) {{
444
+ .grid-2 {{ grid-template-columns: 1fr; }}
445
+ }}
446
+
447
+ .heatmap-wrap {{
448
+ overflow-x: auto;
449
+ }}
450
+
451
+ .heatmap-table {{
452
+ border-collapse: collapse;
453
+ font-size: 12px;
454
+ }}
455
+
456
+ .heatmap-table th,
457
+ .heatmap-table td {{
458
+ padding: 6px 8px;
459
+ text-align: center;
460
+ white-space: nowrap;
461
+ }}
462
+
463
+ .heatmap-table th {{
464
+ color: var(--muted);
465
+ font-weight: 500;
466
+ }}
467
+
468
+ .heatmap-cell {{
469
+ border-radius: 6px;
470
+ font-weight: 600;
471
+ color: #fff;
472
+ min-width: 46px;
473
+ padding: 4px 0;
474
+ }}
475
+
476
+ .explorer-note {{
477
+ font-size: 12px;
478
+ color: var(--muted);
479
+ margin: 0 0 16px 0;
480
+ }}
481
+
482
+ .explorer-controls {{
483
+ display: flex;
484
+ align-items: center;
485
+ gap: 10px;
486
+ margin-bottom: 20px;
487
+ }}
488
+
489
+ .explorer-controls label {{
490
+ font-size: 13px;
491
+ color: var(--muted);
492
+ }}
493
+
494
+ .explorer-controls select {{
495
+ border: 1px solid var(--border);
496
+ border-radius: 8px;
497
+ padding: 8px 10px;
498
+ font-size: 13px;
499
+ background: var(--card-bg);
500
+ color: var(--text);
501
+ }}
502
+
503
+ .explorer-block h4 {{
504
+ margin: 0 0 10px 0;
505
+ font-size: 13px;
506
+ font-weight: 600;
507
+ color: var(--muted);
508
+ }}
509
+
510
+ .explorer-hist-bars {{
511
+ display: flex;
512
+ align-items: flex-end;
513
+ gap: 4px;
514
+ height: 110px;
515
+ }}
516
+
517
+ .explorer-hist-bar-wrap {{
518
+ flex: 1;
519
+ display: flex;
520
+ flex-direction: column;
521
+ align-items: center;
522
+ justify-content: flex-end;
523
+ height: 100%;
524
+ min-width: 0;
525
+ }}
526
+
527
+ .explorer-hist-count {{
528
+ font-size: 11px;
529
+ font-weight: 600;
530
+ color: var(--text);
531
+ margin-bottom: 2px;
532
+ }}
533
+
534
+ .hist-bar {{
535
+ width: 100%;
536
+ background: var(--accent);
537
+ border-radius: 2px 2px 0 0;
538
+ min-height: 2px;
539
+ }}
540
+
541
+ .explorer-hist-edge {{
542
+ font-size: 10px;
543
+ color: var(--muted);
544
+ margin-top: 4px;
545
+ white-space: nowrap;
546
+ overflow: hidden;
547
+ text-overflow: ellipsis;
548
+ max-width: 100%;
549
+ }}
550
+
551
+ .explorer-stats {{
552
+ display: flex;
553
+ flex-wrap: wrap;
554
+ gap: 10px;
555
+ margin-top: 20px;
556
+ padding-top: 16px;
557
+ border-top: 1px solid var(--border);
558
+ }}
559
+
560
+ .stat-chip {{
561
+ background: var(--bg);
562
+ border: 1px solid var(--border);
563
+ border-radius: 10px;
564
+ padding: 8px 12px;
565
+ text-align: center;
566
+ min-width: 76px;
567
+ }}
568
+
569
+ .stat-chip-value {{
570
+ display: block;
571
+ font-size: 15px;
572
+ font-weight: 700;
573
+ }}
574
+
575
+ .stat-chip-label {{
576
+ display: block;
577
+ font-size: 11px;
578
+ color: var(--muted);
579
+ margin-top: 2px;
580
+ }}
581
+
582
+ .box-plot-svg {{
583
+ width: 100%;
584
+ height: 46px;
585
+ display: block;
586
+ }}
587
+
588
+ .box-whisker {{
589
+ stroke: var(--muted);
590
+ stroke-width: 1.5;
591
+ }}
592
+
593
+ .box-rect {{
594
+ fill: #f0f6ff;
595
+ stroke: var(--accent);
596
+ stroke-width: 1.5;
597
+ }}
598
+
599
+ .box-median {{
600
+ stroke: var(--accent);
601
+ stroke-width: 2;
602
+ }}
603
+
604
+ .box-outlier {{
605
+ fill: var(--danger);
606
+ opacity: 0.7;
607
+ }}
608
+ </style>
609
+ </head>
610
+ <body>
611
+ <div class="container">
612
+
613
+ <header>
614
+ <h1>🕵️ Data Detective Report</h1>
615
+ <p>Automated data profiling summary</p>
616
+ </header>
617
+
618
+ <div class="stat-grid">
619
+ <div class="stat-card">
620
+ <div class="value">{rows}</div>
621
+ <div class="label">Rows</div>
622
+ </div>
623
+ <div class="stat-card">
624
+ <div class="value">{cols}</div>
625
+ <div class="label">Columns</div>
626
+ </div>
627
+ <div class="stat-card">
628
+ <div class="value">{duplicates}</div>
629
+ <div class="label">Duplicate rows</div>
630
+ </div>
631
+ </div>
632
+
633
+ <div class="box">
634
+ <h2>🔗 Correlation Heatmap</h2>
635
+ {_render_correlation_heatmap(report.get("correlation_matrix", {}))}
636
+ </div>
637
+
638
+ <div class="box">
639
+ <h2>🔍 Column Explorer</h2>
640
+ {_render_column_explorer(report)}
641
+ </div>
642
+
643
+ <div class="grid-2">
644
+ <div class="box">
645
+ <h2>📉 Missing Values (%)</h2>
646
+ {_render_kv_table(report.get("missing_percentage", {}), "No missing values.")}
647
+ </div>
648
+
649
+ <div class="box">
650
+ <h2>📊 Outliers (IQR)</h2>
651
+ {_render_kv_table(report.get("outliers_iqr", {}), "No outliers detected.")}
652
+ </div>
653
+ </div>
654
+
655
+ <div class="grid-2">
656
+ <div class="box">
657
+ <h2>📊 Outliers (MAD)</h2>
658
+ {_render_kv_table(report.get("outliers_mad", {}), "No outliers detected.")}
659
+ </div>
660
+
661
+ <div class="box">
662
+ <h2>🧠 Distribution Shape</h2>
663
+ {_render_nested_table(report.get("distribution_shape", {}), "No numeric columns.")}
664
+ </div>
665
+ </div>
666
+
667
+ <div class="grid-2">
668
+ <div class="box">
669
+ <h2>🆔 High Cardinality Columns</h2>
670
+ {_render_list(report.get("high_cardinality_columns", []), "None found.")}
671
+ </div>
672
+
673
+ <div class="box">
674
+ <h2>⚠️ Constant Columns</h2>
675
+ {_render_list(report.get("constant_columns", []), "None found.")}
676
+ </div>
677
+ </div>
678
+
679
+ <div class="grid-2">
680
+ <div class="box">
681
+ <h2>🧬 Duplicate Columns</h2>
682
+ {_render_pairs(report.get("duplicate_columns", []), "None found.")}
683
+ </div>
684
+
685
+ <div class="box">
686
+ <h2>🔗 Correlated Columns</h2>
687
+ {_render_pairs(report.get("correlated_columns", []), "None found.")}
688
+ </div>
689
+ </div>
690
+
691
+ <div class="grid-2">
692
+ <div class="box">
693
+ <h2>🧩 Mixed Type Columns</h2>
694
+ {_render_list(report.get("mixed_type_columns", []), "None found.")}
695
+ </div>
696
+
697
+ <div class="box">
698
+ <h2>➖ Negative Values</h2>
699
+ {_render_list(report.get("negative_in_nonnegative_columns", []), "None found.")}
700
+ </div>
701
+ </div>
702
+
703
+ <div class="grid-2">
704
+ <div class="box">
705
+ <h2>💾 Memory Usage (KB)</h2>
706
+ {_render_kv_table(report.get("memory_usage_kb", {}), "No data.")}
707
+ </div>
708
+
709
+ <div class="box">
710
+ <h2>📝 Text Column Stats</h2>
711
+ {_render_nested_table(report.get("text_column_stats", {}), "No text columns.")}
712
+ </div>
713
+ </div>
714
+
715
+ <div class="box">
716
+ <h2>📅 Date-like Columns</h2>
717
+ {_render_list(report.get("date_like_columns", []), "None found.")}
718
+ </div>
719
+
720
+ <div class="box">
721
+ <h2>🧠 Insights</h2>
722
+ {_render_insights(report.get("insights", []))}
723
+ </div>
724
+
725
+ </div>
726
+ </body>
727
+ </html>"""
728
+
729
+ Path(output_path).write_text(html, encoding="utf-8")
730
+ print(f"📄 HTML report generated: {output_path}")