modelmark 0.1.0__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,734 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import platform
5
+ import subprocess
6
+ from html import escape
7
+ from pathlib import Path
8
+ from pathlib import Path
9
+ from typing import Iterable
10
+ from playwright.sync_api import sync_playwright
11
+
12
+ import torch
13
+ import pandas as pd
14
+
15
+ import logging
16
+ logger = logging.getLogger(__name__)
17
+ from rich.console import Console
18
+ console = Console()
19
+ from modelmark.common.utils import load_config
20
+ config = load_config()
21
+
22
+ class Report:
23
+
24
+ """
25
+ This class forms a paper-style table based on model evaluation data.
26
+ And saves results in both .html and .png formats that are easy to embed to any research paper or a website.
27
+ """
28
+
29
+ def __init__(self):
30
+
31
+ self.html_path = "result.html"
32
+ self.png_path = "result.png"
33
+ self.machine_info = self._get_machine_info()
34
+
35
+ def _extract_mean(self, value) -> float:
36
+ """Extract the mean from values such as '0.3561 ± 0.0275'."""
37
+ if isinstance(value, str):
38
+ return float(value.split("±")[0].strip())
39
+ return float(value)
40
+
41
+ def _highlight_best(
42
+ self,
43
+ row: "pd.Series",
44
+ columns: Iterable[tuple],
45
+ higher_is_better: set[str] | bool = False,
46
+ ) -> set[tuple]:
47
+ """
48
+ Given one row (a Series indexed by (model, metric) tuples) and the
49
+ (model, metric) column tuples that actually exist for this row,
50
+ return the set of (model, metric) tuples holding the best value
51
+ for each metric.
52
+
53
+ higher_is_better:
54
+ - False (default): every metric is "lower is better"
55
+ - True: every metric is "higher is better"
56
+ - a set of metric names: only those metrics are treated as
57
+ "higher is better"; everything else is "lower is better"
58
+
59
+ Only compares columns that actually exist for this row, so models
60
+ with different metric sets never cause a KeyError.
61
+ """
62
+ best: set[tuple] = set()
63
+
64
+ by_metric: dict[str, list[tuple]] = {}
65
+ for model, metric in columns:
66
+ by_metric.setdefault(metric, []).append((model, metric))
67
+
68
+ for metric, cols in by_metric.items():
69
+ values = {}
70
+ for col in cols:
71
+ try:
72
+ values[col] = self._extract_mean(row[col])
73
+ except (ValueError, TypeError, KeyError):
74
+ continue
75
+
76
+ if not values:
77
+ continue
78
+
79
+ want_max = higher_is_better is True or (
80
+ isinstance(higher_is_better, set) and metric in higher_is_better
81
+ )
82
+ best_col = max(values, key=values.get) if want_max else min(values, key=values.get)
83
+ best.add(best_col)
84
+
85
+ return best
86
+
87
+ def _ensure_contiguous_datasets(self, df: "pd.DataFrame") -> "pd.DataFrame":
88
+ """
89
+ Reorder rows so every row belonging to the same top-level index
90
+ value ("Dataset") is contiguous, preserving first-appearance order
91
+ of datasets and original order within each dataset.
92
+
93
+ Required for HTML rowspan on the dataset cell to render correctly
94
+ — a rowspan just covers the next N rows in document order, so if
95
+ a dataset's rows aren't contiguous, the span will visually cover
96
+ rows belonging to a different dataset.
97
+ """
98
+ dataset_order = list(dict.fromkeys(df.index.get_level_values(0)))
99
+ if len(dataset_order) <= 1:
100
+ return df
101
+ return pd.concat([df.xs(d, level=0, drop_level=False) for d in dataset_order])
102
+
103
+ def _build_stats_rows(
104
+ self,
105
+ model_stats: dict,
106
+ models: list,
107
+ metrics_by_model: dict,
108
+ highlight_best: bool,
109
+ ) -> str:
110
+ """
111
+ Build one summary row per (label -> per-model value) entry in
112
+ model_stats. Unlike the main body, these are per-model, not
113
+ per-metric — e.g. training time, parameter count, GFLOPs — so
114
+ each value cell spans all of that model's metric columns.
115
+
116
+ Each entry in model_stats is either:
117
+ - a list/array of values, one per model, in the same order
118
+ as `models` (i.e. the order models appear left-to-right
119
+ in the table), or
120
+ - a dict mapping model name -> value.
121
+
122
+ highlight_best bolds the smallest value per row (time, params,
123
+ and FLOPs are always "smaller is better").
124
+ """
125
+ rows_html = []
126
+
127
+ for label, values in model_stats.items():
128
+ if isinstance(values, dict):
129
+ by_model = values
130
+ else:
131
+ values = list(values)
132
+ if len(values) != len(models):
133
+ raise ValueError(
134
+ f"model_stats[{label!r}] has {len(values)} values but there are "
135
+ f"{len(models)} models ({models}); pass one value per model, in "
136
+ f"that order, or a dict keyed by model name."
137
+ )
138
+ by_model = dict(zip(models, values))
139
+
140
+ numeric = {}
141
+ for model in models:
142
+ try:
143
+ numeric[model] = self._extract_mean(by_model[model])
144
+ except (ValueError, TypeError, KeyError):
145
+ continue
146
+
147
+ best_models = set()
148
+ if highlight_best and numeric:
149
+ best_val = min(numeric.values())
150
+ best_models = {m for m, v in numeric.items() if v == best_val}
151
+
152
+ cells = [f'<th class="stats-label" colspan="2">{escape(str(label))}</th>']
153
+ for model in models:
154
+ colspan = len(metrics_by_model[model])
155
+ classes = ["stats-value"]
156
+ if model in best_models:
157
+ classes.append("best")
158
+ value = by_model.get(model, "")
159
+ cells.append(
160
+ f'<td class="{" ".join(classes)}" colspan="{colspan}">{escape(str(value))}</td>'
161
+ )
162
+
163
+ rows_html.append('<tr class="stats-row">' + "".join(cells) + "</tr>")
164
+
165
+ return "".join(rows_html)
166
+
167
+ def dataframe_to_html(
168
+ self,
169
+ df: "pd.DataFrame",
170
+ model_stats: dict,
171
+ metadata: dict,
172
+ title: str = "Neural Network Model Evaluation",
173
+ subtitle: str | None = None,
174
+ higher_is_better: set[str] | bool = False,
175
+ zebra_stripe: bool = True,
176
+ highlight_best_stats: bool = True,
177
+ ) -> str:
178
+ """
179
+ Create a complete standalone HTML report from a DataFrame.
180
+
181
+ Parameters
182
+ ----------
183
+ df:
184
+ The multi-index dataframe with 2 columns and rows levels,
185
+ should be formed as in the example.
186
+ model_stats:
187
+ Per-model summary rows appended below the results,
188
+ e.g. training time / parameter count / GFLOPs — one value per
189
+ model rather than per metric, so each value spans that
190
+ model's whole column group. Example:
191
+
192
+ model_stats={
193
+ "Time (s)": [12.3, 9.8],
194
+ "Params (M)": [25.6, 22.1],
195
+ "GFLOPs": [4.1, 3.9],
196
+ }
197
+
198
+ Each list must be in the same order as the models appear in
199
+ the table (left to right); a dict of {model_name: value} is
200
+ also accepted per row if you'd rather not rely on order.
201
+ metadata:
202
+ The dictonary with system and hardware info.
203
+ title:
204
+ The name of the table or research.
205
+ subtitle:
206
+ Optional additional info about the research-related details.
207
+ html_path:
208
+ Path to the file that should contain the generated html code.
209
+ higher_is_better:
210
+ Passed through to the best-value highlighting. False (default)
211
+ treats every metric as lower-is-better; pass True to flip all
212
+ metrics, or a set of metric names to flip only those (e.g.
213
+ {"Accuracy", "F1"}).
214
+ zebra_stripe:
215
+ Light alternating row shading for on-screen readability.
216
+ Set False for a stricter print/booktabs look.
217
+ highlight_best_stats:
218
+ Bold the smallest value in each model_stats row (time,
219
+ params, and FLOPs are always "smaller is better").
220
+
221
+ The function writes the HTML file and returns the generated HTML.
222
+ """
223
+
224
+ html_path = self.html_path
225
+ html_path = Path(html_path)
226
+
227
+ # ---------------------------------------------------------
228
+ # Validate DataFrame structure
229
+ # ---------------------------------------------------------
230
+
231
+ if not isinstance(df.index, pd.MultiIndex):
232
+ raise ValueError("DataFrame index must be a MultiIndex, e.g. ['Dataset', 'Context'].")
233
+
234
+ if not isinstance(df.columns, pd.MultiIndex):
235
+ raise ValueError("DataFrame columns must be a MultiIndex, e.g. ['Model', 'Metric'].")
236
+
237
+ if df.index.nlevels != 2:
238
+ raise ValueError(f"Expected 2 index levels, got {df.index.nlevels}.")
239
+
240
+ if df.columns.nlevels != 2:
241
+ raise ValueError(f"Expected 2 column levels, got {df.columns.nlevels}.")
242
+
243
+ df = self._ensure_contiguous_datasets(df)
244
+
245
+ # Names used in the table (display labels only — lookups below
246
+ # are positional, so these can be anything, including None).
247
+ column_name_1 = df.columns.names[0] or "Model"
248
+ column_name_2 = df.columns.names[1] or "Metric"
249
+
250
+ # ---------------------------------------------------------
251
+ # Header
252
+ # ---------------------------------------------------------
253
+
254
+ model_values = list(df.columns.get_level_values(0))
255
+ models = list(dict.fromkeys(model_values)) # preserve order
256
+
257
+ metrics_by_model = {}
258
+ for model in models:
259
+ metrics_by_model[model] = list(
260
+ dict.fromkeys(metric for current_model, metric in df.columns if current_model == model)
261
+ )
262
+
263
+ # First header row: model names, each with a short "cmidrule"
264
+ # underline spanning only its own metric columns.
265
+ header_row_1 = f"""
266
+ <tr class="header-main">
267
+ <th class="index-header corner-label" colspan="2">{escape(str(column_name_1))}</th>
268
+ """
269
+ for model in models:
270
+ colspan = len(metrics_by_model[model])
271
+ header_row_1 += f"""
272
+ <th colspan="{colspan}" class="model-header">
273
+ <div class="model-header-inner">{escape(str(model))}</div>
274
+ </th>
275
+ """
276
+ header_row_1 += "</tr>"
277
+
278
+ # Second header row: metric names, with a full-width rule beneath
279
+ # separating the header from the body.
280
+ header_row_2 = f"""
281
+ <tr class="header-metric">
282
+ <th class="corner-placeholder corner-label" colspan="2">{escape(str(column_name_2))}</th>
283
+ """
284
+ for model in models:
285
+ for metric in metrics_by_model[model]:
286
+ header_row_2 += f"""
287
+ <th>{escape(str(metric))}</th>
288
+ """
289
+ header_row_2 += "</tr>"
290
+
291
+ # ---------------------------------------------------------
292
+ # Body
293
+ # ---------------------------------------------------------
294
+
295
+ body_rows = []
296
+
297
+ dataset_rowspans = {}
298
+ for dataset, group in df.groupby(level=0, sort=False):
299
+ dataset_rowspans[dataset] = len(group)
300
+
301
+ dataset_rendered = set()
302
+ previous_dataset = None
303
+
304
+ for row_number, (index, row) in enumerate(df.iterrows()):
305
+ dataset = index[0]
306
+ context = index[1]
307
+
308
+ best_columns = self._highlight_best(row, df.columns, higher_is_better=higher_is_better)
309
+
310
+ cells = []
311
+
312
+ # Dataset cell — only on the first row of each dataset block,
313
+ # spanning all of that dataset's rows.
314
+ if dataset not in dataset_rendered:
315
+ rowspan = dataset_rowspans[dataset]
316
+ cells.append(
317
+ f'<th class="dataset-cell" rowspan="{rowspan}">{escape(str(dataset))}</th>'
318
+ )
319
+ dataset_rendered.add(dataset)
320
+
321
+ # Context cell
322
+ cells.append(f'<th class="context-cell">{escape(str(context))}</th>')
323
+
324
+ # Metric cells
325
+ for model in models:
326
+ for metric in metrics_by_model[model]:
327
+ value = row[(model, metric)]
328
+ classes = ["value-cell"]
329
+ if (model, metric) in best_columns:
330
+ classes.append("best")
331
+ cells.append(f'<td class="{" ".join(classes)}">{escape(str(value))}</td>')
332
+
333
+ row_classes = []
334
+ if zebra_stripe and row_number % 2 == 1:
335
+ row_classes.append("striped")
336
+ if previous_dataset is not None and dataset != previous_dataset:
337
+ row_classes.append("dataset-start")
338
+ previous_dataset = dataset
339
+
340
+ body_rows.append(f'<tr class="{" ".join(row_classes)}">' + "".join(cells) + "</tr>")
341
+
342
+ # ---------------------------------------------------------
343
+ # Per-model summary rows (Time, Params, GFLOPs, ...)
344
+ # ---------------------------------------------------------
345
+
346
+ stats_rows_html = ""
347
+ if model_stats:
348
+ stats_rows_html = self._build_stats_rows(model_stats, models, metrics_by_model, highlight_best_stats)
349
+
350
+ # ---------------------------------------------------------
351
+ # Metadata
352
+ # ---------------------------------------------------------
353
+
354
+ metadata_rows = []
355
+ for key, value in metadata.items():
356
+ metadata_rows.append(
357
+ f"""
358
+ <div class="metadata-row">
359
+ <div class="metadata-label">{escape(str(key))}</div>
360
+ <div class="metadata-value">{escape(str(value))}</div>
361
+ </div>
362
+ """
363
+ )
364
+ metadata_html = "\n".join(metadata_rows)
365
+
366
+ subtitle_html = ""
367
+ if subtitle:
368
+ subtitle_html = f'<div class="subtitle">{escape(subtitle)}</div>'
369
+
370
+ # ---------------------------------------------------------
371
+ # Complete document
372
+ # ---------------------------------------------------------
373
+
374
+ html = f"""<!DOCTYPE html>
375
+ <html lang="en">
376
+ <head>
377
+ <meta charset="UTF-8">
378
+ <title>{escape(title)}</title>
379
+ <style>
380
+
381
+ * {{
382
+ box-sizing: border-box;
383
+ }}
384
+
385
+ body {{
386
+ margin: 0;
387
+ padding: 0;
388
+ background: #ffffff;
389
+ color: #111111;
390
+ font-family: "Times New Roman", Georgia, serif;
391
+ }}
392
+
393
+ .report {{
394
+ display: inline-block;
395
+ padding: 20px;
396
+ }}
397
+
398
+ .title {{
399
+ font-size: 22px;
400
+ font-weight: 600;
401
+ margin-bottom: 4px;
402
+ }}
403
+
404
+ .subtitle {{
405
+ color: #666666;
406
+ font-size: 13px;
407
+ margin-bottom: 22px;
408
+ overflow-wrap: break-word;
409
+ white-space: pre-wrap;
410
+ }}
411
+
412
+ /* ============================================================
413
+ TABLE — booktabs style: no vertical rules, rules top/bottom
414
+ ============================================================ */
415
+
416
+ table {{
417
+ border-collapse: collapse;
418
+ font-size: 13.5px;
419
+ border-top: 2px solid #000000;
420
+ border-bottom: 2px solid #000000;
421
+ }}
422
+
423
+ thead th {{
424
+ padding: 6px 16px;
425
+ text-align: center;
426
+ font-weight: 400;
427
+ }}
428
+
429
+ .header-main th {{
430
+ font-weight: 600;
431
+ padding-bottom: 2px;
432
+ }}
433
+
434
+ .header-main .model-header {{
435
+ font-size: 14px;
436
+ }}
437
+
438
+ .model-header-inner {{
439
+ display: block;
440
+ margin: 0 6px 4px;
441
+ padding-bottom: 4px;
442
+ border-bottom: 1px solid #000000;
443
+ }}
444
+
445
+ .header-metric th {{
446
+ font-size: 12.5px;
447
+ font-weight: 400;
448
+ color: #333333;
449
+ border-bottom: 1px solid #000000;
450
+ padding-bottom: 6px;
451
+ }}
452
+
453
+ .corner-label {{
454
+ font-size: 12.5px;
455
+ font-weight: 400;
456
+ color: #000000;
457
+ text-transform: uppercase;
458
+ letter-spacing: 0.03em;
459
+ }}
460
+
461
+ /* ============================================================
462
+ BODY
463
+ ============================================================ */
464
+
465
+ tbody th,
466
+ tbody td {{
467
+ padding: 6px 16px;
468
+ }}
469
+
470
+ .dataset-cell {{
471
+ text-align: left;
472
+ vertical-align: middle;
473
+ font-weight: 600;
474
+ white-space: nowrap;
475
+ }}
476
+
477
+ .context-cell {{
478
+ text-align: left;
479
+ padding-left: 24px !important;
480
+ vertical-align: middle;
481
+ white-space: nowrap;
482
+ font-style: italic;
483
+ color: #444444;
484
+ font-weight: 400;
485
+ }}
486
+
487
+ .value-cell {{
488
+ text-align: center;
489
+ white-space: nowrap;
490
+ font-variant-numeric: tabular-nums;
491
+ }}
492
+
493
+ tr.striped td,
494
+ tr.striped th {{
495
+ background: #f7f7f7;
496
+ }}
497
+
498
+ tr.dataset-start td,
499
+ tr.dataset-start th {{
500
+ border-top: 1px solid #cccccc;
501
+ }}
502
+
503
+ /* ============================================================
504
+ PER-MODEL SUMMARY STATS (time, params, FLOPs, ...)
505
+ ============================================================ */
506
+
507
+ tbody.stats tr:first-child td,
508
+ tbody.stats tr:first-child th {{
509
+ border-top: 1px solid #000000;
510
+ }}
511
+
512
+ .stats-label {{
513
+ text-align: left;
514
+ font-size: 12px;
515
+ font-style: italic;
516
+ font-weight: 400;
517
+ color: #555555;
518
+ }}
519
+
520
+ .stats-value {{
521
+ text-align: center;
522
+ white-space: nowrap;
523
+ font-variant-numeric: tabular-nums;
524
+ font-size: 12.5px;
525
+ color: #333333;
526
+ }}
527
+
528
+ /* ============================================================
529
+ BEST RESULTS
530
+ ============================================================ */
531
+
532
+ .best {{
533
+ font-weight: 700;
534
+ }}
535
+
536
+ /* ============================================================
537
+ METADATA
538
+ ============================================================ */
539
+
540
+ .metadata {{
541
+ margin-top: 20px;
542
+ padding-top: 10px;
543
+ font-size: 12.5px;
544
+ color: #444444;
545
+ }}
546
+
547
+ .metadata-title {{
548
+ margin-bottom: 8px;
549
+ color: #111111;
550
+ font-size: 16px;
551
+ font-weight: 600;
552
+ }}
553
+
554
+ .metadata-row {{
555
+ display: grid;
556
+ grid-template-columns: 110px auto;
557
+ margin: 3px 0;
558
+ }}
559
+
560
+ .metadata-label {{
561
+ font-weight: 600;
562
+ color: #222222;
563
+ }}
564
+
565
+ .metadata-value {{
566
+ color: #000000;
567
+ }}
568
+
569
+ </style>
570
+ </head>
571
+ <body>
572
+
573
+ <div class="report">
574
+
575
+ <div class="title">{escape(title)}</div>
576
+ {subtitle_html}
577
+
578
+ <table>
579
+ <thead>
580
+ {header_row_1}
581
+ {header_row_2}
582
+ </thead>
583
+ <tbody>
584
+ {"".join(body_rows)}
585
+ </tbody>
586
+ {f'<tbody class="stats">{stats_rows_html}</tbody>' if stats_rows_html else ""}
587
+ </table>
588
+
589
+ <div class="metadata">
590
+ <div class="metadata-title">Experimental setup</div>
591
+ {metadata_html}
592
+ </div>
593
+
594
+ </div>
595
+
596
+ </body>
597
+ </html>
598
+ """
599
+
600
+ html_path.parent.mkdir(parents=True, exist_ok=True)
601
+ html_path.write_text(html, encoding="utf-8")
602
+
603
+ return html
604
+
605
+ def html_to_png(self, scale: int = 2,):
606
+ """Convert the .html file to the .png image."""
607
+
608
+ html_path = Path(self.html_path).resolve()
609
+ png_path = Path(self.png_path)
610
+
611
+ png_path.parent.mkdir(
612
+ parents=True,
613
+ exist_ok=True,
614
+ )
615
+
616
+ with sync_playwright() as p:
617
+ browser = p.chromium.launch()
618
+
619
+ page = browser.new_page(
620
+ device_scale_factor=scale,
621
+ )
622
+
623
+ page.goto(
624
+ html_path.as_uri(),
625
+ wait_until="networkidle",
626
+ )
627
+
628
+ # Find the actual report element
629
+ report = page.locator(".report")
630
+
631
+ # Take a screenshot of ONLY that element
632
+ report.screenshot(
633
+ path=str(png_path),
634
+ )
635
+
636
+ browser.close()
637
+
638
+ def _get_cpu_name(self) -> str:
639
+
640
+ system = platform.system()
641
+
642
+ if system == "Windows":
643
+ # Uses Windows Management Instrumentation (WMI) via command line
644
+ try:
645
+ command = "wmic cpu get name"
646
+ output = subprocess.check_output(command, shell=True).decode().strip()
647
+ # The output contains a header 'Name', split and grab the actual value
648
+ return output.split("\n")[1].strip()
649
+ except Exception:
650
+ return platform.processor()
651
+
652
+ elif system == "Darwin": # macOS
653
+ # Queries the sysctl kernel utility for the CPU brand string
654
+ try:
655
+ command = ["sysctl", "-n", "machdep.cpu.brand_string"]
656
+ return subprocess.check_output(command).decode().strip()
657
+ except Exception:
658
+ return platform.processor()
659
+
660
+ elif system == "Linux":
661
+ # Reads the system's virtual /proc/cpuinfo file directly
662
+ try:
663
+ with open("/proc/cpuinfo", "r") as f:
664
+ for line in f:
665
+ if "model name" in line:
666
+ # Extract the name after the colon
667
+ return line.split(":", 1)[1].strip()
668
+ except Exception:
669
+ # Fallback if /proc/cpuinfo isn't accessible (e.g., some containers)
670
+ try:
671
+ output = subprocess.check_output("lscpu", shell=True).decode()
672
+ for line in output.splitlines():
673
+ if "Model name:" in line:
674
+ return line.split(":", 1)[1].strip()
675
+ except Exception:
676
+ return platform.processor()
677
+
678
+ return "Unknown Processor"
679
+
680
+ def _get_machine_info(self) -> dict:
681
+ """Get the info about system and hardware, returns as dictonary."""
682
+
683
+ # OS
684
+ os_name = f"{platform.system()} {platform.release()}"
685
+ os_version = f"{platform.version()}"
686
+ arch = f"{platform.machine()}"
687
+ # CPU
688
+ cpu_name = f"{self._get_cpu_name()}"
689
+ # GPU
690
+ gpu_name = "_"
691
+ if torch.cuda.is_available():
692
+ gpu_name = torch.cuda.get_device_name(0)
693
+ # Python version
694
+ python_version = f"{platform.python_version()}"
695
+ # Pytorch version
696
+ pytorch_version = f"{torch.__version__}"
697
+
698
+ return {
699
+ "OS": os_name + " " + os_version + " " + arch,
700
+ "Python": python_version,
701
+ "PyTorch": pytorch_version,
702
+ "CPU": cpu_name,
703
+ "GPU": gpu_name
704
+ }
705
+
706
+ def report(self, df, title, subtitle, model_stats):
707
+
708
+ # Print the report to the console #
709
+
710
+ # Title
711
+ console.print("=" * 128, style="white")
712
+ console.print(" " * 60 + "Report:", style="magenta")
713
+ console.print("=" * 128, style="white")
714
+ # DataFrame
715
+ console.print("-" * 128, style="white")
716
+ console.print(df, style="white")
717
+ console.print("-" * 128, style="white")
718
+ # Stats
719
+ console.print("Training stats:", style="white")
720
+ report_model_names = list(config.data_config)
721
+ console.print(f"{'Metric':<18} | " + " | ".join(f"{h:>10}" for h in report_model_names), style="white")
722
+ console.print("-" * 128, style="white")
723
+ for key, values in model_stats.items():
724
+ console.print(f"{key:<18} | " + " | ".join(f"{float(v):>10.2f}" for v in values), style="white")
725
+ console.print("-" * 128, style="white")
726
+ # Meta
727
+ console.print("Experimental setup:", style="white")
728
+ console.print(json.dumps(self.machine_info, indent = 4), style="white")
729
+ console.print("=" * 128, style="white")
730
+
731
+ # Convert and save the report #
732
+
733
+ self.dataframe_to_html(df=df, model_stats=model_stats, metadata=self.machine_info, title=title, subtitle=subtitle)
734
+ self.html_to_png()