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.
- easyclassifier/__init__.py +23 -0
- easyclassifier/__main__.py +61 -0
- easyclassifier/dataset.py +142 -0
- easyclassifier/demo_data.py +32 -0
- easyclassifier/diagnostics.py +95 -0
- easyclassifier/distances.py +152 -0
- easyclassifier/evaluation.py +340 -0
- easyclassifier/figures.py +510 -0
- easyclassifier/help_texts.py +113 -0
- easyclassifier/importance.py +79 -0
- easyclassifier/latex_report.py +555 -0
- easyclassifier/logbook.py +31 -0
- easyclassifier/models.py +94 -0
- easyclassifier/preprocessing.py +219 -0
- easyclassifier/recommend.py +128 -0
- easyclassifier/reporting.py +70 -0
- easyclassifier/target.py +202 -0
- easyclassifier/ui.py +281 -0
- easyclassifier/wizard.py +1266 -0
- easyclassifier-0.8.1.dist-info/METADATA +267 -0
- easyclassifier-0.8.1.dist-info/RECORD +25 -0
- easyclassifier-0.8.1.dist-info/WHEEL +5 -0
- easyclassifier-0.8.1.dist-info/entry_points.txt +2 -0
- easyclassifier-0.8.1.dist-info/licenses/LICENSE +21 -0
- easyclassifier-0.8.1.dist-info/top_level.txt +1 -0
|
@@ -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
|