easyclassifier 0.8.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,79 @@
1
+ """Which columns matter? Permutation importance, measured on held-out rows.
2
+
3
+ For each original column, its values are shuffled and the drop in balanced
4
+ accuracy is measured. A large drop means the model relies on that column.
5
+
6
+ * It works with every classifier (including KNN with the Hassanat distance).
7
+ * It is computed on the *original* columns, so a text column such as "city"
8
+ is one variable, not several encoded pieces.
9
+ * It is measured on rows the model was not trained on (the final test set,
10
+ or the test part of each cross-validation fold), so it is not inflated.
11
+ * It changes nothing in the model; it only describes it.
12
+
13
+ Reference: Breiman, L. (2001). Random Forests. Machine Learning, 45, 5-32.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Optional
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+ from sklearn.inspection import permutation_importance
23
+ from sklearn.model_selection import StratifiedKFold, train_test_split
24
+
25
+ N_REPEATS = 5
26
+ MAX_EVAL_ROWS = 500 # rows used per evaluation, to keep it fast
27
+
28
+
29
+ def _perm(model, X_eval, y_eval, seed: int) -> np.ndarray:
30
+ if len(y_eval) > MAX_EVAL_ROWS:
31
+ rng = np.random.default_rng(seed)
32
+ idx = rng.choice(len(y_eval), MAX_EVAL_ROWS, replace=False)
33
+ X_eval, y_eval = X_eval.iloc[idx], y_eval[idx]
34
+ r = permutation_importance(model, X_eval, y_eval,
35
+ scoring="balanced_accuracy",
36
+ n_repeats=N_REPEATS, random_state=seed)
37
+ return r.importances # shape (n_columns, n_repeats)
38
+
39
+
40
+ def honest_importance(spec, X: pd.DataFrame, y, validation: str,
41
+ dev: Optional[np.ndarray] = None,
42
+ test: Optional[np.ndarray] = None,
43
+ seed: int = 0) -> pd.DataFrame:
44
+ """Permutation importance of ``spec`` (an unfitted pipeline factory).
45
+
46
+ With ``dev``/``test`` (final test set): fit on dev, measure on test.
47
+ Otherwise: hold-out split or 5-fold CV, measured on each test part.
48
+ Returns a table sorted from most to least important.
49
+ """
50
+ y = np.asarray(y)
51
+ parts = []
52
+ if dev is not None and test is not None:
53
+ model = spec.factory().fit(X.iloc[dev], y[dev])
54
+ parts.append(_perm(model, X.iloc[test], y[test], seed))
55
+ how = "untouched final test set"
56
+ elif validation == "holdout":
57
+ idx = np.arange(len(y))
58
+ tr, te = train_test_split(idx, test_size=0.2, random_state=42,
59
+ stratify=y)
60
+ model = spec.factory().fit(X.iloc[tr], y[tr])
61
+ parts.append(_perm(model, X.iloc[te], y[te], seed))
62
+ how = "hold-out test part"
63
+ else:
64
+ smallest = int(np.min(np.unique(y, return_counts=True)[1]))
65
+ cv = StratifiedKFold(n_splits=max(2, min(5, smallest)),
66
+ shuffle=True, random_state=42)
67
+ for i, (tr, te) in enumerate(cv.split(X, y)):
68
+ model = spec.factory().fit(X.iloc[tr], y[tr])
69
+ parts.append(_perm(model, X.iloc[te], y[te], seed + i))
70
+ how = "test part of each cross-validation fold"
71
+
72
+ allv = np.concatenate(parts, axis=1)
73
+ table = pd.DataFrame({
74
+ "column": list(X.columns),
75
+ "importance": allv.mean(axis=1),
76
+ "std": allv.std(axis=1),
77
+ }).sort_values("importance", ascending=False).reset_index(drop=True)
78
+ table.attrs["measured_on"] = how
79
+ return table
@@ -0,0 +1,555 @@
1
+ """Analysis report as a LaTeX file, compiled to PDF when LaTeX is available.
2
+
3
+ The report is written for non-programmers and contains:
4
+
5
+ 1. the data and the question (what was predicted),
6
+ 2. a ready-to-adapt *Methods* paragraph for a paper or thesis,
7
+ 3. results: the honest final score, the comparison of classifiers,
8
+ figures, and which columns mattered,
9
+ 4. what each number means, in plain words,
10
+ 5. references to cite.
11
+
12
+ ``report.tex`` is always written. If a LaTeX engine (tectonic, pdflatex,
13
+ xelatex or lualatex) is installed, ``report.pdf`` is produced from it;
14
+ otherwise the .tex file can be uploaded to an online editor such as Overleaf.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import datetime as _dt
20
+ import os
21
+ import shutil
22
+ import subprocess
23
+ from dataclasses import dataclass, field
24
+ from typing import Dict, List, Optional, Tuple
25
+
26
+ import pandas as pd
27
+
28
+ from .distances import HASSANAT_CITATION
29
+ from .evaluation import METRICS, SELECTION_LABEL, VALIDATION
30
+ from .help_texts import explain
31
+
32
+ SKLEARN_CITATION = ("Pedregosa, F., et al. (2011). Scikit-learn: Machine "
33
+ "Learning in Python. Journal of Machine Learning "
34
+ "Research, 12, 2825-2830.")
35
+ BREIMAN_CITATION = ("Breiman, L. (2001). Random Forests. Machine Learning, "
36
+ "45, 5-32.")
37
+
38
+ PERCENT_METRICS = {"accuracy", "precision", "recall", "f1",
39
+ "balanced_accuracy", "specificity"}
40
+
41
+
42
+ # --------------------------------------------------------------------------- #
43
+ # Everything the report needs, collected by the wizard
44
+ # --------------------------------------------------------------------------- #
45
+
46
+ @dataclass
47
+ class ReportContext:
48
+ software_citation: str
49
+ version: str
50
+ dataset: str
51
+ rows_loaded: int
52
+ cols_loaded: int
53
+ target: str
54
+ target_grouping: str
55
+ class_counts: Dict[str, int]
56
+ rows_used: int
57
+ predictor_columns: List[str]
58
+ left_out: Dict[str, str]
59
+ data_notes: List[str]
60
+ impute: Optional[str] # None if no missing values were filled
61
+ encoding: Optional[str] # None if no text columns
62
+ scaled: Dict[str, List[str]] # "standard"/"minmax" -> classifier names
63
+ classifiers: List[str]
64
+ knn_text: str # "" if KNN not used
65
+ hassanat_used: bool
66
+ validation: str
67
+ selection: Optional[str]
68
+ n_dev: int
69
+ n_test: int
70
+ best_name: str
71
+ final: object # Result or None
72
+ results: List[object] # Results, ranked
73
+ metrics: List[str]
74
+ # figure key -> {format: path relative to the results folder}
75
+ figures: Dict[str, Dict[str, str]] = field(default_factory=dict)
76
+ importance: Optional[pd.DataFrame] = None
77
+ comparison_text: str = ""
78
+ learning_text: str = ""
79
+
80
+
81
+ # --------------------------------------------------------------------------- #
82
+ # LaTeX helpers
83
+ # --------------------------------------------------------------------------- #
84
+
85
+ _TEX = {
86
+ "\\": r"\textbackslash{}", "&": r"\&", "%": r"\%", "$": r"\$",
87
+ "#": r"\#", "_": r"\_", "{": r"\{", "}": r"\}",
88
+ "~": r"\textasciitilde{}", "^": r"\textasciicircum{}",
89
+ "<": r"\textless{}", ">": r"\textgreater{}",
90
+ }
91
+
92
+
93
+ def tex(s) -> str:
94
+ """Escape text for LaTeX. Characters pdfLaTeX cannot typeset are
95
+ replaced with '?', so unusual column names never break compilation."""
96
+ out = []
97
+ for ch in str(s):
98
+ if ch in _TEX:
99
+ out.append(_TEX[ch])
100
+ elif ord(ch) < 256:
101
+ out.append(ch)
102
+ else:
103
+ out.append("?")
104
+ return "".join(out)
105
+
106
+
107
+ def _val(metric: str, v: float) -> str:
108
+ return f"{v * 100:.2f}\\%" if metric in PERCENT_METRICS else f"{v:.4f}"
109
+
110
+
111
+ # Captions: what the figure shows and how to read it, in plain words.
112
+ CAPTIONS = {
113
+ "class_distribution": "Number of rows in each class. Very unequal "
114
+ "classes make some measures (e.g.\\ accuracy) "
115
+ "look better than they are; balanced accuracy "
116
+ "takes this into account.",
117
+ "missing_values": "Empty cells in the data as loaded. Left: each dark "
118
+ "mark is an empty cell (rows run left to right). "
119
+ "Right: the share of empty cells in each column.",
120
+ "correlation": "Correlation between numeric columns, from $-1$ (one "
121
+ "goes up when the other goes down) through 0 (no linear "
122
+ "relation) to $+1$ (they go up together). Values near "
123
+ "$\\pm 1$ mean two columns carry almost the same "
124
+ "information.",
125
+ "comparison": "Balanced accuracy of each classifier. Dots are the "
126
+ "individual test folds, the vertical bar is the overall "
127
+ "score. The wider the dots are spread, the less certain "
128
+ "the ranking. The diamond, if shown, is the final score "
129
+ "of the selected classifier on data not used to choose "
130
+ "it.",
131
+ "confusion_matrix": "Confusion matrix. Rows are the true classes, "
132
+ "columns the predicted classes; each cell shows the "
133
+ "number of rows and the percentage of that true "
134
+ "class. The diagonal holds the correct predictions; "
135
+ "off-diagonal cells show which classes are "
136
+ "confused.",
137
+ "roc_curve": "ROC curve(s). The closer a curve comes to the top-left "
138
+ "corner, the better that class is separated from the "
139
+ "others; the dotted diagonal is random guessing. AUC "
140
+ "summarises each curve (1 = perfect, 0.5 = random).",
141
+ "pr_curve": "Precision-recall curve(s), most informative when a class is "
142
+ "rare. The higher and further right a curve, the better. "
143
+ "The dotted line is the level a random guess would reach.",
144
+ "feature_importance": "Permutation importance: how much the balanced "
145
+ "accuracy drops when a column is shuffled "
146
+ "(mean and spread over repeated shuffles).",
147
+ "columns_by_class": "The most important columns, shown for each class. "
148
+ "For numbers, each box covers the middle half of the "
149
+ "values, the line is the median. For text columns, "
150
+ "bars show how common each category is in each "
151
+ "class.",
152
+ "learning_curve": "Learning curve. The lower line is the score on new "
153
+ "rows as more training rows are used; shaded bands "
154
+ "show the variation between folds. A line still "
155
+ "rising at the right means more data would probably "
156
+ "help; a flat line means it probably would not. A "
157
+ "large gap between the two lines means the model "
158
+ "partly memorises its training rows.",
159
+ }
160
+
161
+
162
+ def _fig(c, key: str, caption: str, width: str = "0.62") -> str:
163
+ """Figure block for ``key``, preferring the vector PDF version."""
164
+ files = c.figures.get(key)
165
+ if not files:
166
+ return ""
167
+ path = files.get("pdf") or files.get("png")
168
+ if not path:
169
+ return ""
170
+ return _figure(path, caption, width)
171
+
172
+
173
+ def _figure(path: str, caption: str, width: str = "0.62") -> str:
174
+ return (
175
+ "\\begin{figure}[H]\n\\centering\n"
176
+ f"\\includegraphics[width={width}\\textwidth]"
177
+ f"{{{path.replace(os.sep, '/')}}}\n"
178
+ f"\\caption{{{caption}}}\n\\end{{figure}}\n"
179
+ )
180
+
181
+
182
+ def _join(items: List[str]) -> str:
183
+ items = list(items)
184
+ if len(items) <= 1:
185
+ return "".join(items)
186
+ return ", ".join(items[:-1]) + " and " + items[-1]
187
+
188
+
189
+ # --------------------------------------------------------------------------- #
190
+ # Sections
191
+ # --------------------------------------------------------------------------- #
192
+
193
+ def _methods_paragraph(c: ReportContext) -> str:
194
+ k = len(c.class_counts)
195
+ parts = [
196
+ f"Classification was carried out with EasyClassifier version "
197
+ f"{tex(c.version)} \\cite{{easyclassifier}}, which is built on "
198
+ f"scikit-learn \\cite{{sklearn}}.",
199
+ f"The dataset ({tex(c.dataset)}) contained {c.rows_loaded} rows and "
200
+ f"{c.cols_loaded} columns. The outcome to predict was "
201
+ f"\\emph{{{tex(c.target)}}} with {k} classes.",
202
+ ]
203
+ if c.target_grouping:
204
+ parts.append(
205
+ f"Because {tex(c.target)} is a numeric measurement, it was "
206
+ f"divided into groups before modelling: "
207
+ f"{tex(c.target_grouping)}.")
208
+ if c.left_out:
209
+ parts.append(
210
+ "Columns that could not contribute to prediction were excluded ("
211
+ + tex(_join([f"{col}: {why}" for col, why in c.left_out.items()]))
212
+ + ").")
213
+ for note in c.data_notes:
214
+ parts.append(tex(note))
215
+ parts.append(f"The analysis used {c.rows_used} rows and "
216
+ f"{len(c.predictor_columns)} predictor columns.")
217
+
218
+ prep = []
219
+ if c.impute:
220
+ label = "most frequent value" if c.impute == "mode" else c.impute
221
+ prep.append(f"missing numeric values were filled with the {label} "
222
+ "and missing text values with the most frequent value")
223
+ if c.encoding:
224
+ enc = {
225
+ "onehot": "one-hot encoded",
226
+ "label": "ordinal-encoded",
227
+ "auto": ("one-hot encoded when they had at most 10 categories "
228
+ "and ordinal-encoded otherwise"),
229
+ }[c.encoding]
230
+ prep.append(f"text columns were {enc}")
231
+ how = []
232
+ if c.scaled.get("standard"):
233
+ how.append("standardised (mean 0, standard deviation 1) for "
234
+ + tex(_join(c.scaled["standard"])))
235
+ if c.scaled.get("minmax"):
236
+ how.append("scaled to the range 0--1 for "
237
+ + tex(_join(c.scaled["minmax"])))
238
+ if how:
239
+ prep.append("numeric columns were " + ", and ".join(how))
240
+ if prep:
241
+ parts.append("Before modelling, " + "; ".join(prep) + ". All of these "
242
+ "steps were learned from the training data only, "
243
+ "separately within every validation split, so that no "
244
+ "information from test data entered model training.")
245
+
246
+ parts.append(
247
+ "The following classifiers were used with their default parameters, "
248
+ "without hyperparameter tuning: " + tex(_join(c.classifiers)) + ".")
249
+ if c.knn_text:
250
+ cite = " \\cite{hassanat2014}" if c.hassanat_used else ""
251
+ parts.append(tex(c.knn_text) + cite + ".")
252
+ parts.append(f"Performance was estimated with "
253
+ f"{tex(VALIDATION[c.validation].lower())}.")
254
+
255
+ if c.final is not None and c.selection == "final_test":
256
+ parts.append(
257
+ f"The classifier with the highest {SELECTION_LABEL.lower()} was "
258
+ f"selected. To avoid optimistic bias from this selection, "
259
+ f"{c.n_test} rows (20\\%, stratified by class) were set aside "
260
+ f"before any analysis; classifiers were compared on the "
261
+ f"remaining {c.n_dev} rows only, and the selected classifier was "
262
+ f"evaluated once on the untouched test rows.")
263
+ elif c.final is not None and c.selection == "nested":
264
+ parts.append(
265
+ f"The classifier with the highest {SELECTION_LABEL.lower()} was "
266
+ f"selected. To avoid optimistic bias from this selection, "
267
+ f"performance was estimated with nested 5-fold cross-validation, "
268
+ f"in which the complete comparison was repeated within each "
269
+ f"outer training fold and the winner was evaluated on the outer "
270
+ f"test fold.")
271
+ if c.importance is not None:
272
+ parts.append(
273
+ "The contribution of each predictor was estimated by permutation "
274
+ "importance \\cite{breiman2001}, i.e.\\ the drop in "
275
+ f"{SELECTION_LABEL.lower()} when that column's values were "
276
+ "randomly shuffled, measured on the "
277
+ f"{tex(c.importance.attrs.get('measured_on', 'held-out rows'))}.")
278
+ return " ".join(parts)
279
+
280
+
281
+ def _metric_table(result, metrics: List[str]) -> str:
282
+ rows = []
283
+ for m in metrics:
284
+ if m in result.metrics:
285
+ rows.append(f"{tex(METRICS[m])} & {_val(m, result.metrics[m])} & "
286
+ f"\\parbox[t]{{0.55\\textwidth}}{{\\small "
287
+ f"{tex(explain(m))}}}\\\\[3pt]")
288
+ return ("\\begin{tabular}{llp{0.57\\textwidth}}\n\\toprule\n"
289
+ "Measure & Value & What it means\\\\\n\\midrule\n"
290
+ + "\n".join(rows) + "\n\\bottomrule\n\\end{tabular}\n")
291
+
292
+
293
+ def _comparison_table(results, metrics: List[str]) -> str:
294
+ extra = [m for m in ("accuracy", "f1", "roc_auc")
295
+ if m in metrics and m != "balanced_accuracy"]
296
+ head = "Classifier & " + SELECTION_LABEL + "".join(
297
+ f" & {tex(METRICS[m])}" for m in extra)
298
+ lines = []
299
+ for r in results:
300
+ cells = [tex(r.classifier_name),
301
+ _val("balanced_accuracy", r.selection_score)]
302
+ cells += [_val(m, r.metrics[m]) if m in r.metrics else "--"
303
+ for m in extra]
304
+ lines.append(" & ".join(cells) + "\\\\")
305
+ cols = "l" + "r" * (1 + len(extra))
306
+ return (f"\\begin{{tabular}}{{{cols}}}\n\\toprule\n{head}\\\\\n"
307
+ "\\midrule\n" + "\n".join(lines)
308
+ + "\n\\bottomrule\n\\end{tabular}\n")
309
+
310
+
311
+ def build_tex(c: ReportContext) -> str:
312
+ date = _dt.date.today().strftime("%d %B %Y")
313
+ shown = c.final if c.final is not None else (c.results[0]
314
+ if c.results else None)
315
+ L = []
316
+ L.append(r"""\documentclass[11pt,a4paper]{article}
317
+ \usepackage[utf8]{inputenc}
318
+ \usepackage[T1]{fontenc}
319
+ \IfFileExists{lmodern.sty}{\usepackage{lmodern}}{}
320
+ \usepackage[margin=2.2cm]{geometry}
321
+ \usepackage{graphicx}
322
+ \usepackage{booktabs}
323
+ \usepackage{float}
324
+ \usepackage[hidelinks]{hyperref}
325
+ \setlength{\parskip}{0.5em}
326
+ \setlength{\parindent}{0pt}
327
+ \begin{document}
328
+ """)
329
+ L.append("\\begin{center}\n{\\LARGE EasyClassifier analysis report}\\\\"
330
+ f"[4pt]\n{{\\large {tex(c.dataset)}}}\\\\[2pt]\n"
331
+ f"{date} \\quad$\\cdot$\\quad EasyClassifier "
332
+ f"{tex(c.version)}\n\\end{{center}}\n")
333
+
334
+ # 1. Summary ---------------------------------------------------------- #
335
+ L.append("\\section*{Summary}\n")
336
+ if shown is not None:
337
+ key = ("accuracy" if "accuracy" in shown.metrics else
338
+ next(iter(shown.metrics), None))
339
+ what = (f"\\emph{{{tex(c.target)}}}"
340
+ + (f" (grouped: {tex(c.target_grouping)})"
341
+ if c.target_grouping else ""))
342
+ s = (f"The goal was to predict {what} from "
343
+ f"{len(c.predictor_columns)} other columns, using "
344
+ f"{c.rows_used} rows. ")
345
+ if len(c.results) > 1:
346
+ s += (f"{len(c.results)} classifiers were compared and "
347
+ f"\\textbf{{{tex(c.best_name)}}} was selected. ")
348
+ else:
349
+ s += f"The classifier used was \\textbf{{{tex(c.best_name)}}}. "
350
+ if key:
351
+ s += (f"Its {'honest final ' if c.final is not None else ''}"
352
+ f"{tex(METRICS[key].lower())} was "
353
+ f"\\textbf{{{_val(key, shown.metrics[key])}}}")
354
+ chance = 100.0 / max(1, len(c.class_counts))
355
+ s += (f" (guessing at random would give about {chance:.0f}\\% "
356
+ "with equally sized classes).")
357
+ L.append(s + "\n")
358
+
359
+ # 2. Data -------------------------------------------------------------- #
360
+ L.append("\\section{Data and question}\n")
361
+ L.append(f"File: {tex(c.dataset)}; {c.rows_loaded} rows and "
362
+ f"{c.cols_loaded} columns were loaded. Rows used in the "
363
+ f"analysis: {c.rows_used}.\n\n")
364
+ total = sum(c.class_counts.values()) or 1
365
+ L.append("\\begin{table}[H]\n\\centering\n\\begin{tabular}{lrr}\n"
366
+ "\\toprule\nClass & Rows & Share\\\\\n\\midrule\n")
367
+ for cls, n in c.class_counts.items():
368
+ L.append(f"{tex(cls)} & {n} & {100 * n / total:.1f}\\%\\\\\n")
369
+ L.append("\\bottomrule\n\\end{tabular}\n\\caption{Classes of "
370
+ f"\\emph{{{tex(c.target)}}}.}}\n\\end{{table}}\n")
371
+ L.append("Predictor columns: " + tex(", ".join(map(str,
372
+ c.predictor_columns)))
373
+ + ".\n\n")
374
+ if c.left_out:
375
+ L.append("Left out: " + tex("; ".join(
376
+ f"{k} ({v})" for k, v in c.left_out.items())) + ".\n\n")
377
+ L.append(_fig(c, "class_distribution", CAPTIONS["class_distribution"]))
378
+ L.append(_fig(c, "missing_values", CAPTIONS["missing_values"],
379
+ width="0.85"))
380
+ L.append(_fig(c, "correlation", CAPTIONS["correlation"], width="0.7"))
381
+
382
+ # 3. Methods ------------------------------------------------------------ #
383
+ L.append("\\section{Methods (ready to adapt for your paper)}\n")
384
+ L.append("The paragraph below describes exactly what was done. You may "
385
+ "copy and adapt it for the methods section of a paper or "
386
+ "thesis.\n\n")
387
+ L.append("\\begin{quote}\n" + _methods_paragraph(c) + "\n\\end{quote}\n")
388
+
389
+ # 4. Results ------------------------------------------------------------ #
390
+ L.append("\\section{Results}\n")
391
+ if shown is not None:
392
+ if c.final is not None:
393
+ where = ("the untouched 20\\% test set"
394
+ if c.selection == "final_test"
395
+ else "nested cross-validation")
396
+ L.append(f"\\subsection*{{Final result for {tex(c.best_name)} "
397
+ f"(measured on {where}) -- report these numbers}}\n")
398
+ if getattr(c.final, "note", ""):
399
+ L.append(tex(c.final.note) + ".\n\n")
400
+ else:
401
+ L.append(f"\\subsection*{{Result for {tex(c.best_name)}}}\n")
402
+ L.append("\\begin{table}[H]\n\\centering\n"
403
+ + _metric_table(shown, c.metrics)
404
+ + "\\end{table}\n")
405
+ if len(c.results) > 1:
406
+ L.append("\\subsection*{Comparison of classifiers}\n")
407
+ if c.final is not None:
408
+ L.append("These scores were used only to choose the best "
409
+ "classifier. They are slightly optimistic, because the "
410
+ "winner partly won by luck; report the final result "
411
+ "above instead.\n\n")
412
+ L.append("\\begin{table}[H]\n\\centering\n\\small\n"
413
+ + _comparison_table(c.results, c.metrics)
414
+ + "\\caption{Classifiers ranked by "
415
+ f"{SELECTION_LABEL.lower()}.}}\n\\end{{table}}\n")
416
+ if c.comparison_text:
417
+ L.append(tex(c.comparison_text) + "\n\n")
418
+ L.append(_fig(c, "comparison", CAPTIONS["comparison"],
419
+ width="0.8"))
420
+
421
+ if any(k in c.figures for k in ("confusion_matrix", "roc_curve",
422
+ "pr_curve")):
423
+ L.append("\\subsection*{Figures for "
424
+ f"{tex(c.best_name)}"
425
+ + (" (final result)" if c.final is not None else "")
426
+ + "}\n")
427
+ for key in ("confusion_matrix", "roc_curve", "pr_curve"):
428
+ L.append(_fig(c, key, CAPTIONS[key]))
429
+
430
+ if c.importance is not None and len(c.importance):
431
+ L.append("\\subsection*{Which columns mattered}\n")
432
+ L.append("Each column's values were shuffled, and the drop in "
433
+ f"{SELECTION_LABEL.lower()} was measured on rows the model "
434
+ "had not been trained on. A larger drop means the model "
435
+ "relies more on that column. Values near zero (or below) "
436
+ "mean the column did not help. Importance shows what the "
437
+ "model uses; it does not prove cause and effect.\n\n")
438
+ L.append(_fig(c, "feature_importance",
439
+ CAPTIONS["feature_importance"], width="0.72"))
440
+ top = c.importance.head(10)
441
+ L.append("\\begin{table}[H]\n\\centering\n\\small\n"
442
+ "\\begin{tabular}{lrr}\n\\toprule\nColumn & Importance & "
443
+ "Spread\\\\\n\\midrule\n")
444
+ for _, r in top.iterrows():
445
+ L.append(f"{tex(r['column'])} & {r['importance']:.4f} & "
446
+ f"{r['std']:.4f}\\\\\n")
447
+ cap = ("The ten most important columns." if len(c.importance) > 10
448
+ else "Importance of each column.")
449
+ L.append(f"\\bottomrule\n\\end{{tabular}}\n\\caption{{{cap}}}\n"
450
+ "\\end{table}\n")
451
+ L.append(_fig(c, "columns_by_class", CAPTIONS["columns_by_class"],
452
+ width="0.95"))
453
+
454
+ if "learning_curve" in c.figures:
455
+ L.append("\\subsection*{Would more data help?}\n")
456
+ if c.learning_text:
457
+ L.append(tex(c.learning_text) + "\n\n")
458
+ L.append(_fig(c, "learning_curve", CAPTIONS["learning_curve"]))
459
+
460
+ # 5. Notes -------------------------------------------------------------- #
461
+ L.append("\\section{Good practice when reporting}\n")
462
+ L.append("\\begin{itemize}\n"
463
+ "\\item Report the final result, not the best score from the "
464
+ "comparison table.\n"
465
+ "\\item Mention the number of rows and the class sizes; results "
466
+ "for small classes are less reliable.\n"
467
+ "\\item Classifiers used default parameters (no tuning), so the "
468
+ "results can be reproduced exactly with the same data and "
469
+ "EasyClassifier version.\n"
470
+ "\\item All choices and steps are recorded in "
471
+ "\\texttt{log.txt} in the Results folder.\n"
472
+ "\\end{itemize}\n")
473
+
474
+ # 6. References --------------------------------------------------------- #
475
+ L.append("\\begin{thebibliography}{9}\n")
476
+ L.append(f"\\bibitem{{easyclassifier}} {tex(c.software_citation)}\n")
477
+ L.append(f"\\bibitem{{sklearn}} {tex(SKLEARN_CITATION)}\n")
478
+ if c.hassanat_used:
479
+ L.append(f"\\bibitem{{hassanat2014}} {tex(HASSANAT_CITATION)}\n")
480
+ if c.importance is not None:
481
+ L.append(f"\\bibitem{{breiman2001}} {tex(BREIMAN_CITATION)}\n")
482
+ L.append("\\end{thebibliography}\n\\end{document}\n")
483
+ return "".join(L)
484
+
485
+
486
+ # --------------------------------------------------------------------------- #
487
+ # Writing and compiling
488
+ # --------------------------------------------------------------------------- #
489
+
490
+ ENGINES = ["tectonic", "pdflatex", "xelatex", "lualatex"]
491
+
492
+
493
+ def find_engine() -> Optional[str]:
494
+ for e in ENGINES:
495
+ if shutil.which(e):
496
+ return e
497
+ return None
498
+
499
+
500
+ def write_and_compile(c: ReportContext, out_dir: str,
501
+ compile_pdf: bool = True,
502
+ timeout: int = 180) -> Tuple[str, Optional[str], str]:
503
+ """Write report.tex; compile it to report.pdf if possible.
504
+
505
+ Returns (tex_path, pdf_path or None, message for the user).
506
+ """
507
+ tex_path = os.path.join(out_dir, "report.tex")
508
+ with open(tex_path, "w", encoding="utf-8") as fh:
509
+ fh.write(build_tex(c))
510
+ if not compile_pdf:
511
+ return tex_path, None, "PDF not requested."
512
+
513
+ engine = find_engine()
514
+ if engine is None:
515
+ return tex_path, None, (
516
+ "No LaTeX program was found on this computer, so only "
517
+ "report.tex was written. To get the PDF, upload report.tex and "
518
+ "the 'figures' folder to an online LaTeX editor such as "
519
+ "Overleaf, or install a LaTeX distribution (e.g. MiKTeX on "
520
+ "Windows, MacTeX on macOS, TeX Live on Linux).")
521
+
522
+ pdf_path = os.path.join(out_dir, "report.pdf")
523
+ if os.path.exists(pdf_path):
524
+ os.remove(pdf_path)
525
+ if engine == "tectonic":
526
+ runs = [["tectonic", "report.tex"]]
527
+ else:
528
+ cmd = [engine, "-interaction=nonstopmode", "-halt-on-error",
529
+ "report.tex"]
530
+ runs = [cmd, cmd] # twice, for references
531
+ try:
532
+ for cmd in runs:
533
+ proc = subprocess.run(cmd, cwd=out_dir, capture_output=True,
534
+ timeout=timeout)
535
+ if proc.returncode != 0:
536
+ break
537
+ except (subprocess.TimeoutExpired, OSError) as exc:
538
+ return tex_path, None, f"LaTeX ({engine}) did not finish: {exc}."
539
+ finally:
540
+ for ext in (".aux", ".out", ".toc"):
541
+ p = os.path.join(out_dir, "report" + ext)
542
+ if os.path.exists(p):
543
+ os.remove(p)
544
+
545
+ log = os.path.join(out_dir, "report.log")
546
+ if os.path.exists(pdf_path) and proc.returncode == 0:
547
+ if os.path.exists(log):
548
+ os.remove(log)
549
+ return tex_path, pdf_path, f"PDF created with {engine}."
550
+ if os.path.exists(log):
551
+ os.replace(log, os.path.join(out_dir, "report_latex_errors.log"))
552
+ return tex_path, None, (
553
+ f"LaTeX ({engine}) could not build the PDF; details are in "
554
+ "report_latex_errors.log. report.tex can still be opened in an "
555
+ "online editor such as Overleaf.")
@@ -0,0 +1,31 @@
1
+ """Simple session log that records every choice and step for reproducibility."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as _dt
6
+ from typing import List
7
+
8
+
9
+ class LogBook:
10
+ def __init__(self) -> None:
11
+ self._lines: List[str] = []
12
+ self.add(f"EasyClassifier session started {self._now()}")
13
+
14
+ @staticmethod
15
+ def _now() -> str:
16
+ return _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
17
+
18
+ def add(self, text: str) -> None:
19
+ self._lines.append(f"[{self._now()}] {text}")
20
+
21
+ def section(self, title: str) -> None:
22
+ self._lines.append("")
23
+ self._lines.append(f"== {title} ==")
24
+
25
+ def text(self) -> str:
26
+ return "\n".join(self._lines) + "\n"
27
+
28
+ def save(self, path: str) -> str:
29
+ with open(path, "w", encoding="utf-8") as fh:
30
+ fh.write(self.text())
31
+ return path