genetic-classifier 3.0.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,10 @@
1
+ """genetic-classifier: linear classifier (binary and ordinal) trained by a genetic algorithm (Prandiano)."""
2
+
3
+ from .core import predict, train
4
+ from .evaluation import confusion_matrix, evaluate
5
+ from .reading import read_csv, read_excel
6
+ from .split import train_test_split
7
+
8
+ __all__ = ["train", "predict", "evaluate", "confusion_matrix", "read_excel", "read_csv", "train_test_split"]
9
+
10
+ __version__ = "3.0.0"
@@ -0,0 +1,339 @@
1
+ """Main loop of the genetic algorithm: training (train) and prediction (predict)."""
2
+
3
+ import numpy as np
4
+
5
+ from .creation import create_chromosomes
6
+ from .crossover import one_point_crossover
7
+ from .fitness import binary_fitness, fitness_proportions, ordinal_fitness
8
+ from .mutation import mutate
9
+ from .selection import select_parents_roulette
10
+ from .update import update_population
11
+
12
+
13
+ def _check_features(X: np.ndarray) -> None:
14
+ """
15
+ Check that X only holds valid numbers. Without this, an empty cell in the
16
+ spreadsheet would silently turn into a wrong predicted class.
17
+
18
+ Parameters
19
+ ----------
20
+ X : np.ndarray
21
+ Data to check, of any shape.
22
+
23
+ Returns
24
+ -------
25
+ None
26
+ Raises TypeError if X has text or another non-numeric value, and
27
+ ValueError if X has NaN or infinite values.
28
+ """
29
+ try:
30
+ values = X.astype(float)
31
+ except (TypeError, ValueError):
32
+ raise TypeError("X must contain only numbers, but found text or another type of value") from None
33
+
34
+ if not np.isfinite(values).all():
35
+ raise ValueError("X has empty cells or invalid values (NaN or infinity), fix the data before continuing")
36
+
37
+
38
+ def train(
39
+ X: np.ndarray,
40
+ y: np.ndarray,
41
+ n_chromosomes: int = 30,
42
+ n_mutated_genes: int = 2,
43
+ n_offspring: int = 16,
44
+ n_replacements: int | None = None,
45
+ n_generations: int = 1000,
46
+ target_fitness: float = 1.0,
47
+ thresholds: np.ndarray | None = None,
48
+ gene_min: float = -1.0,
49
+ gene_max: float = 1.0,
50
+ seed: int | None = None,
51
+ verbose: bool = True,
52
+ ) -> dict:
53
+ """
54
+ Train a linear classifier with the full genetic algorithm: create the
55
+ population and repeat fitness -> selection -> crossover -> mutation ->
56
+ update until reaching target_fitness or running out of generations.
57
+
58
+ Parameters
59
+ ----------
60
+ X : np.ndarray of shape (n_samples, n_features)
61
+ Training data, numbers only (no empty cells).
62
+ y : np.ndarray of shape (n_samples,)
63
+ True class of each sample: 0 or 1 for binary, integers from 0 to K-1
64
+ for ordinal.
65
+ n_chromosomes : int, default=30
66
+ Population size.
67
+ n_mutated_genes : int, default=2
68
+ How many genes change in each child during mutation, from 0 to n_features + 1.
69
+ n_offspring : int, default=16
70
+ How many children are created per generation.
71
+ n_replacements : int or None, default=None
72
+ How many of the worst chromosomes are swapped for the best children, at
73
+ most min(n_chromosomes, n_offspring). None = half of n_offspring.
74
+ n_generations : int, default=1000
75
+ Maximum number of generations.
76
+ target_fitness : float, default=1.0
77
+ Stops when the best fitness reaches this value, between 0 and 1
78
+ (1.0 = only stops if every sample is right, so it usually runs every generation).
79
+ thresholds : np.ndarray of shape (K-1,) or None, default=None
80
+ None for 2 classes (binary), or K-1 cut points in increasing order for
81
+ K classes (ordinal).
82
+ gene_min : float, default=-1.0
83
+ Smallest gene value in the initial population.
84
+ gene_max : float, default=1.0
85
+ Largest gene value in the initial population. Must be greater than gene_min.
86
+ seed : int or None, default=None
87
+ Random seed for reproducibility (None = different result every run).
88
+ verbose : bool, default=True
89
+ If True, prints the best fitness and accuracy of every generation.
90
+
91
+ Returns
92
+ -------
93
+ model : dict
94
+ The trained model, ready to be passed to predict(). Keys:
95
+
96
+ - ``"chromosome"`` (np.ndarray of shape (n_features + 1,)): best
97
+ chromosome found. Position 0 is the bias, the rest are the weights.
98
+ - ``"fitness"`` (float): fitness of the best chromosome on the training data, 0 to 1.
99
+ - ``"accuracy"`` (float): fraction of the samples of X that the best
100
+ chromosome gets right, 0 to 1.
101
+ - ``"generation"`` (int): generation where training stopped.
102
+ - ``"thresholds"`` (np.ndarray or None): thresholds used (None if binary).
103
+ - ``"history"`` (dict of lists of float, one value per generation):
104
+ ``"best"`` = best fitness so far, ``"mean"`` = mean fitness of the
105
+ population, ``"accuracy"`` = accuracy of the best chromosome so far.
106
+
107
+ Examples
108
+ --------
109
+ >>> model = train(X_train, y_train, seed=0)
110
+ >>> model["accuracy"]
111
+ """
112
+
113
+ if not isinstance(X, np.ndarray):
114
+ raise TypeError("X must be an ndarray")
115
+
116
+ if not isinstance(y, np.ndarray):
117
+ raise TypeError("y must be an ndarray")
118
+
119
+ if X.ndim != 2:
120
+ raise ValueError(f"X must be a 2D matrix (n_samples, n_features), got {X.ndim} dimensions")
121
+
122
+ if y.ndim != 1:
123
+ raise ValueError(f"y must be a 1D vector, got {y.ndim} dimensions")
124
+
125
+ if X.shape[0] != y.shape[0]:
126
+ raise ValueError(f"X has {X.shape[0]} samples, but y has {y.shape[0]} values")
127
+
128
+ if X.shape[1] == 0:
129
+ raise ValueError("X has no features (at least 1 column is needed)")
130
+
131
+ _check_features(X)
132
+
133
+ if not isinstance(n_chromosomes, int):
134
+ raise TypeError("n_chromosomes must be an integer")
135
+
136
+ if n_chromosomes <= 0:
137
+ raise ValueError(f"n_chromosomes must be >= 1, got {n_chromosomes}")
138
+
139
+ if not isinstance(n_mutated_genes, int):
140
+ raise TypeError("n_mutated_genes must be an integer")
141
+
142
+ if n_mutated_genes < 0 or n_mutated_genes > X.shape[1] + 1:
143
+ raise ValueError(f"n_mutated_genes must be between 0 and {X.shape[1] + 1} (number of genes), got {n_mutated_genes}")
144
+
145
+ if not isinstance(n_offspring, int):
146
+ raise TypeError("n_offspring must be an integer")
147
+
148
+ if n_offspring <= 0:
149
+ raise ValueError(f"n_offspring must be >= 1, got {n_offspring}")
150
+
151
+ if n_replacements is None:
152
+ n_replacements = min(max(n_offspring // 2, 1), n_chromosomes)
153
+
154
+ if not isinstance(n_replacements, int):
155
+ raise TypeError("n_replacements must be an integer or None")
156
+
157
+ if n_replacements < 0 or n_replacements > min(n_chromosomes, n_offspring):
158
+ raise ValueError(f"n_replacements must be between 0 and {min(n_chromosomes, n_offspring)} (smaller of n_chromosomes and n_offspring), got {n_replacements}")
159
+
160
+ if not isinstance(n_generations, int):
161
+ raise TypeError("n_generations must be an integer")
162
+
163
+ if n_generations <= 0:
164
+ raise ValueError(f"n_generations must be >= 1, got {n_generations}")
165
+
166
+ if not isinstance(target_fitness, (int, float)):
167
+ raise TypeError("target_fitness must be a number")
168
+
169
+ if target_fitness < 0 or target_fitness > 1:
170
+ raise ValueError(f"target_fitness must be between 0 and 1, got {target_fitness}")
171
+
172
+ if not isinstance(gene_min, (int, float)):
173
+ raise TypeError("gene_min must be a number")
174
+
175
+ if not isinstance(gene_max, (int, float)):
176
+ raise TypeError("gene_max must be a number")
177
+
178
+ if gene_min >= gene_max:
179
+ raise ValueError(f"gene_min ({gene_min}) must be smaller than gene_max ({gene_max})")
180
+
181
+ if seed is not None and (not isinstance(seed, int) or isinstance(seed, bool)):
182
+ raise TypeError("seed must be an integer or None")
183
+
184
+ if not isinstance(verbose, bool):
185
+ raise TypeError("verbose must be True or False")
186
+
187
+ if thresholds is not None and not isinstance(thresholds, np.ndarray):
188
+ raise TypeError("thresholds must be an ndarray or None")
189
+
190
+ if seed is not None:
191
+ np.random.seed(seed)
192
+
193
+ # convert once: X @ weights with an integer X (e.g. output of read_csv) is ~6x slower than with float
194
+ X = X.astype(float)
195
+
196
+ n_genes = X.shape[1] + 1 # +1 for the bias
197
+
198
+ if thresholds is None:
199
+ def compute_fitness(chromosomes):
200
+ return binary_fitness(chromosomes, X, y)
201
+ else:
202
+ thresholds = np.asarray(thresholds)
203
+
204
+ def compute_fitness(chromosomes):
205
+ return ordinal_fitness(chromosomes, X, y, thresholds)
206
+
207
+ population = create_chromosomes(n_chromosomes, n_genes, gene_min, gene_max)
208
+
209
+ best_chromosome = None
210
+ best_fitness = -np.inf
211
+ best_accuracy = 0.0
212
+ final_generation = 0
213
+ history = {"best": [], "mean": [], "accuracy": []}
214
+
215
+ for generation in range(n_generations):
216
+ fitnesses = compute_fitness(population)
217
+ proportions = fitness_proportions(fitnesses)
218
+
219
+ # best of the current generation
220
+ best_index = int(np.argmax(fitnesses))
221
+ current_fitness = float(fitnesses[best_index])
222
+
223
+ # update the overall best, if it improved
224
+ if current_fitness > best_fitness:
225
+ best_fitness = current_fitness
226
+ best_chromosome = population[best_index].copy()
227
+
228
+ # accuracy = hits / total, only recomputed when the best changes
229
+ y_pred = predict({"chromosome": best_chromosome, "thresholds": thresholds}, X)
230
+ best_accuracy = float(np.mean(y_pred == y))
231
+
232
+ final_generation = generation + 1
233
+ history["best"].append(best_fitness)
234
+ history["mean"].append(float(fitnesses.mean()))
235
+ history["accuracy"].append(best_accuracy)
236
+
237
+ if verbose:
238
+ print(f"Generation {final_generation:>4} | best fitness: {best_fitness:.4f} | accuracy: {best_accuracy:.2%}")
239
+
240
+ # stopping criterion: target fitness
241
+ if best_fitness >= target_fitness:
242
+ if verbose:
243
+ print(f"\nTarget fitness {target_fitness} reached at generation {final_generation}.")
244
+ break
245
+
246
+ # no offspring in the last generation: they would never compete with the best
247
+ if final_generation == n_generations:
248
+ break
249
+
250
+ # selection -> crossover -> mutation -> fitness -> replacement
251
+ parent1, parent2 = select_parents_roulette(population, proportions)
252
+ offspring = one_point_crossover(parent1, parent2, n_offspring)
253
+ offspring = mutate(offspring, n_mutated_genes)
254
+ offspring_fitnesses = compute_fitness(offspring)
255
+ population = update_population(
256
+ population, fitnesses,
257
+ offspring, offspring_fitnesses,
258
+ n_replacements,
259
+ )
260
+
261
+ return {
262
+ "chromosome": best_chromosome,
263
+ "fitness": best_fitness,
264
+ "accuracy": best_accuracy,
265
+ "generation": final_generation,
266
+ "thresholds": thresholds,
267
+ "history": history,
268
+ }
269
+
270
+
271
+ def predict(model: dict, X: np.ndarray) -> np.ndarray:
272
+ """
273
+ Use the best chromosome found by train() to classify new data.
274
+
275
+ Parameters
276
+ ----------
277
+ model : dict
278
+ Model returned by train(). Needs the keys ``"chromosome"`` and ``"thresholds"``.
279
+ X : np.ndarray of shape (n_features,) or (n_samples, n_features)
280
+ One sample (1D) or several samples (2D), numbers only, with the same
281
+ features used in training.
282
+
283
+ Returns
284
+ -------
285
+ y_pred : np.ndarray of shape (n_samples,), dtype int
286
+ Predicted class of each sample (shape (1,) when X is a single sample).
287
+
288
+ Examples
289
+ --------
290
+ >>> y_pred = predict(model, X_test)
291
+ """
292
+
293
+ if not isinstance(model, dict):
294
+ raise TypeError("model must be a dict (returned by train())")
295
+
296
+ if "chromosome" not in model:
297
+ raise ValueError("invalid model: missing the 'chromosome' key")
298
+
299
+ if "thresholds" not in model:
300
+ raise ValueError("invalid model: missing the 'thresholds' key")
301
+
302
+ if model["chromosome"] is None:
303
+ raise ValueError("model was not trained (chromosome is None)")
304
+
305
+ if not isinstance(model["chromosome"], np.ndarray):
306
+ raise TypeError("model['chromosome'] must be an ndarray")
307
+
308
+ if model["thresholds"] is not None and not isinstance(model["thresholds"], np.ndarray):
309
+ raise TypeError("model['thresholds'] must be an ndarray or None")
310
+
311
+ if not isinstance(X, np.ndarray):
312
+ raise TypeError("X must be an ndarray")
313
+
314
+ if X.ndim not in (1, 2):
315
+ raise ValueError(f"X must be 1D (one sample) or 2D (several samples), got {X.ndim}D")
316
+
317
+ _check_features(X)
318
+
319
+ chromosome = model["chromosome"]
320
+ thresholds = model["thresholds"]
321
+
322
+ bias = chromosome[0]
323
+ weights = chromosome[1:]
324
+
325
+ # a single sample (1D) becomes a (1, n_features) matrix so it works the same way
326
+ if X.ndim == 1:
327
+ X = X.reshape(1, -1)
328
+
329
+ # the number of features must match the chromosome
330
+ if X.shape[1] != len(weights):
331
+ raise ValueError(
332
+ f"X has {X.shape[1]} features, but the chromosome was trained with {len(weights)} features"
333
+ )
334
+
335
+ scores = np.dot(X, weights) + bias
336
+
337
+ if thresholds is None:
338
+ return np.where(scores >= 0, 1, 0)
339
+ return np.digitize(scores, thresholds)
@@ -0,0 +1,47 @@
1
+ # order: step 1 (start) -> next: fitness.py
2
+ """Creation of the initial population of chromosomes."""
3
+
4
+ import numpy as np
5
+
6
+
7
+ def create_chromosomes(n_chromosomes: int = 6, n_genes: int = 19,
8
+ gene_min: float = -1.0, gene_max: float = 1.0) -> np.ndarray:
9
+ """
10
+ Create the initial population: a matrix where each row is a chromosome and
11
+ each column is a gene, filled with random values between gene_min and gene_max.
12
+
13
+ Parameters
14
+ ----------
15
+ n_chromosomes : int, default=6
16
+ How many chromosomes to create (the population size). Must be >= 1.
17
+ n_genes : int, default=19
18
+ How many genes each chromosome has: one weight per feature plus one bias,
19
+ so usually ``n_features + 1``. Must be >= 1.
20
+ gene_min : float, default=-1.0
21
+ Smallest value a gene can take.
22
+ gene_max : float, default=1.0
23
+ Largest value a gene can take. Must be greater than gene_min.
24
+
25
+ Returns
26
+ -------
27
+ population : np.ndarray of shape (n_chromosomes, n_genes)
28
+ The new population. Column 0 is the bias, the other columns are the weights.
29
+
30
+ Examples
31
+ --------
32
+ >>> population = create_chromosomes(n_chromosomes=30, n_genes=X.shape[1] + 1)
33
+ """
34
+
35
+ if not isinstance(n_chromosomes, int) or not isinstance(n_genes, int):
36
+ raise TypeError("n_chromosomes and n_genes must be integers")
37
+
38
+ if n_chromosomes <= 0 or n_genes <= 0:
39
+ raise ValueError("at least 1 chromosome and 1 gene are needed")
40
+
41
+ if not isinstance(gene_min, (int, float)) or not isinstance(gene_max, (int, float)):
42
+ raise TypeError("gene_min and gene_max must be numbers")
43
+
44
+ if gene_min >= gene_max:
45
+ raise ValueError(f"gene_min ({gene_min}) must be smaller than gene_max ({gene_max})")
46
+
47
+ return np.random.uniform(gene_min, gene_max, size=(n_chromosomes, n_genes))
@@ -0,0 +1,53 @@
1
+ # order: step 4, comes after selection.py -> next: mutation.py
2
+ """Crossover between two parents."""
3
+
4
+ import numpy as np
5
+
6
+
7
+ def one_point_crossover(parent1: np.ndarray, parent2: np.ndarray, n_offspring: int = 3) -> np.ndarray:
8
+ """
9
+ Create offspring by cutting the parents at a random point: each child takes
10
+ the beginning of parent1 and the end of parent2. Each child gets its own
11
+ cut point.
12
+
13
+ Parameters
14
+ ----------
15
+ parent1 : np.ndarray of shape (n_genes,)
16
+ First parent chosen (gives the genes before the cut).
17
+ parent2 : np.ndarray of shape (n_genes,)
18
+ Second parent chosen (gives the genes from the cut onwards). Must have
19
+ the same number of genes as parent1, at least 2.
20
+ n_offspring : int, default=3
21
+ How many children to create. Must be >= 1.
22
+
23
+ Returns
24
+ -------
25
+ offspring : np.ndarray of shape (n_offspring, n_genes)
26
+ The children, one per row.
27
+
28
+ Examples
29
+ --------
30
+ >>> offspring = one_point_crossover(parent1, parent2, n_offspring=16)
31
+ """
32
+
33
+ if not isinstance(parent1, np.ndarray) or not isinstance(parent2, np.ndarray):
34
+ raise TypeError("parent1 and parent2 must be ndarrays")
35
+
36
+ if len(parent1) != len(parent2):
37
+ raise ValueError("parent1 and parent2 must have the same number of genes")
38
+
39
+ if len(parent1) < 2:
40
+ raise ValueError(f"parent1 and parent2 need at least 2 genes to have a cut point, got {len(parent1)}")
41
+
42
+ if n_offspring <= 0:
43
+ raise ValueError(f"n_offspring must be at least 1, got {n_offspring}")
44
+
45
+ offspring = []
46
+ for _ in range(n_offspring):
47
+ # random integer from 1 to len(parent1)-1, never cuts at the ends
48
+ cut = np.random.randint(1, len(parent1))
49
+ child = parent1.copy() # starts equal to parent1
50
+ child[cut:] = parent2[cut:] # from the cut onwards takes parent2's genes
51
+ offspring.append(child)
52
+
53
+ return np.array(offspring) # turns the list of children into a matrix
@@ -0,0 +1,233 @@
1
+ """Model evaluation after training."""
2
+
3
+ import numpy as np
4
+
5
+
6
+ def _labels_to_int(y: np.ndarray, name: str) -> np.ndarray:
7
+ """
8
+ Return the classes as integers. Accepts floats holding whole numbers
9
+ (e.g. 1.0), which is how a class column may come from a spreadsheet.
10
+
11
+ Parameters
12
+ ----------
13
+ y : np.ndarray of shape (n_samples,)
14
+ Classes to convert.
15
+ name : str
16
+ Name of the parameter being converted, used in the error message.
17
+
18
+ Returns
19
+ -------
20
+ y_int : np.ndarray of shape (n_samples,), dtype int
21
+ The same classes as integers.
22
+ """
23
+ if np.issubdtype(y.dtype, np.integer):
24
+ return y
25
+
26
+ if np.issubdtype(y.dtype, np.floating) and np.isfinite(y).all() and np.all(y == np.floor(y)):
27
+ return y.astype(int)
28
+
29
+ raise TypeError(f"{name} must contain integers from 0 to K-1 (convert text classes first)")
30
+
31
+
32
+ def confusion_matrix(y_true: np.ndarray, y_pred: np.ndarray,
33
+ class_names: list[str] | None = None, verbose: bool = True) -> np.ndarray:
34
+ """
35
+ Build the confusion matrix: each row is the true class, each column is the
36
+ predicted class, and the diagonal holds the correct predictions.
37
+
38
+ Parameters
39
+ ----------
40
+ y_true : np.ndarray of shape (n_samples,)
41
+ True class of each sample, integers from 0 to K-1.
42
+ y_pred : np.ndarray of shape (n_samples,)
43
+ Predicted class of each sample (output of predict).
44
+ class_names : list of str or None, default=None
45
+ Name of each class, in order from 0 to K-1 (None = use the numbers).
46
+ It also sets K, so the matrix is complete even if some class does not
47
+ appear in the data.
48
+ verbose : bool, default=True
49
+ If True, prints the matrix.
50
+
51
+ Returns
52
+ -------
53
+ matrix : np.ndarray of shape (K, K), dtype int
54
+ matrix[i, j] is how many samples of true class i were predicted as class j.
55
+
56
+ Examples
57
+ --------
58
+ >>> matrix = confusion_matrix(y_test, y_pred, class_names=["poisonous", "edible"])
59
+ """
60
+
61
+ if not isinstance(y_true, np.ndarray) or not isinstance(y_pred, np.ndarray):
62
+ raise TypeError("y_true and y_pred must be ndarrays")
63
+
64
+ if y_true.ndim != 1 or y_pred.ndim != 1:
65
+ raise ValueError(f"y_true and y_pred must be 1D vectors, got {y_true.ndim} and {y_pred.ndim} dimensions")
66
+
67
+ if len(y_true) != len(y_pred):
68
+ raise ValueError(f"y_true has {len(y_true)} values, but y_pred has {len(y_pred)}")
69
+
70
+ if len(y_true) == 0:
71
+ raise ValueError("y_true and y_pred cannot be empty")
72
+
73
+ y_true = _labels_to_int(y_true, "y_true")
74
+ y_pred = _labels_to_int(y_pred, "y_pred")
75
+
76
+ if class_names is not None and not isinstance(class_names, list):
77
+ raise TypeError("class_names must be a list or None")
78
+
79
+ if class_names is not None and len(class_names) == 0:
80
+ raise ValueError("class_names cannot be empty")
81
+
82
+ if not isinstance(verbose, bool):
83
+ raise TypeError("verbose must be True or False")
84
+
85
+ smallest_class = min(y_true.min(), y_pred.min())
86
+ largest_class = max(y_true.max(), y_pred.max())
87
+
88
+ if smallest_class < 0:
89
+ raise ValueError(f"classes must be integers starting at 0, found {smallest_class}")
90
+
91
+ if class_names is None:
92
+ K = int(largest_class) + 1
93
+ else:
94
+ K = len(class_names)
95
+ if largest_class >= K:
96
+ raise ValueError(f"class_names has {K} names, but the data has class {largest_class}")
97
+
98
+ matrix = np.zeros((K, K), dtype=int)
99
+ for true, predicted in zip(y_true, y_pred):
100
+ matrix[true, predicted] += 1
101
+
102
+ if verbose:
103
+ _print_matrix(matrix, _display_names(K, class_names))
104
+
105
+ return matrix
106
+
107
+
108
+ def evaluate(y_true: np.ndarray, y_pred: np.ndarray,
109
+ class_names: list[str] | None = None, verbose: bool = True) -> dict:
110
+ """
111
+ Compare the predictions with the true classes: compute the accuracy, the
112
+ fitness, the recall of each class and the confusion matrix.
113
+
114
+ Parameters
115
+ ----------
116
+ y_true : np.ndarray of shape (n_samples,)
117
+ True class of each sample, integers from 0 to K-1.
118
+ y_pred : np.ndarray of shape (n_samples,)
119
+ Predicted class of each sample (output of predict).
120
+ class_names : list of str or None, default=None
121
+ Name of each class, in order from 0 to K-1 (None = use the numbers).
122
+ It also sets K, so the result is complete even if some class does not
123
+ appear in the data.
124
+ verbose : bool, default=True
125
+ If True, prints the results.
126
+
127
+ Returns
128
+ -------
129
+ results : dict
130
+ Dictionary with the keys:
131
+
132
+ - ``"accuracy"`` (float): fraction of samples predicted correctly, 0 to 1.
133
+ - ``"fitness"`` (float): product of the recalls, 0 to 1 (nan if some
134
+ class has no samples).
135
+ - ``"recall"`` (np.ndarray of shape (K,), dtype float): recall of each
136
+ class (nan for a class without samples).
137
+ - ``"confusion_matrix"`` (np.ndarray of shape (K, K), dtype int): same
138
+ as the output of confusion_matrix.
139
+
140
+ Examples
141
+ --------
142
+ >>> results = evaluate(y_test, y_pred)
143
+ >>> results["accuracy"]
144
+ """
145
+
146
+ if not isinstance(verbose, bool):
147
+ raise TypeError("verbose must be True or False")
148
+
149
+ # the other checks are already done by confusion_matrix
150
+ matrix = confusion_matrix(y_true, y_pred, class_names, verbose=False)
151
+
152
+ hits = np.diag(matrix)
153
+ totals = matrix.sum(axis=1)
154
+
155
+ # a class without samples gets nan: its recall cannot be measured
156
+ recall = np.full(len(matrix), np.nan)
157
+ has_samples = totals > 0
158
+ recall[has_samples] = hits[has_samples] / totals[has_samples]
159
+
160
+ accuracy = float(hits.sum() / matrix.sum())
161
+ fitness = float(np.prod(recall))
162
+
163
+ if verbose:
164
+ names = _display_names(len(matrix), class_names)
165
+
166
+ print(f"accuracy: {accuracy:.2%}")
167
+ if np.isnan(fitness):
168
+ print("fitness: not computed (a class has no samples)")
169
+ else:
170
+ print(f"fitness: {fitness:.4f}")
171
+
172
+ print("\nrecall per class:")
173
+ for name, hit, total, value in zip(names, hits, totals, recall):
174
+ if total == 0:
175
+ print(f" {name}: no samples")
176
+ else:
177
+ print(f" {name}: {hit}/{total} = {value:.2%}")
178
+
179
+ print("\nconfusion matrix:")
180
+ _print_matrix(matrix, names)
181
+
182
+ return {
183
+ "accuracy": accuracy,
184
+ "fitness": fitness,
185
+ "recall": recall,
186
+ "confusion_matrix": matrix,
187
+ }
188
+
189
+
190
+ def _display_names(K: int, class_names: list[str] | None) -> list[str]:
191
+ """
192
+ Return the text that represents each class on screen.
193
+
194
+ Parameters
195
+ ----------
196
+ K : int
197
+ Number of classes.
198
+ class_names : list of str or None
199
+ Name of each class, or None to use the numbers 0 to K-1.
200
+
201
+ Returns
202
+ -------
203
+ names : list of str
204
+ K texts, one per class.
205
+ """
206
+ if class_names is None:
207
+ return [str(k) for k in range(K)]
208
+ return [str(name) for name in class_names]
209
+
210
+
211
+ def _print_matrix(matrix: np.ndarray, names: list[str]) -> None:
212
+ """
213
+ Print the confusion matrix with aligned columns.
214
+
215
+ Parameters
216
+ ----------
217
+ matrix : np.ndarray of shape (K, K)
218
+ Confusion matrix.
219
+ names : list of str
220
+ Text that represents each class (K items).
221
+
222
+ Returns
223
+ -------
224
+ None
225
+ Only prints.
226
+ """
227
+ title = "true \\ predicted"
228
+ name_width = max(len(title), *(len(name) for name in names))
229
+ column_width = max(*(len(name) for name in names), len(str(matrix.max()))) + 2
230
+
231
+ print(f"{title:>{name_width}}" + "".join(f"{name:>{column_width}}" for name in names))
232
+ for name, row in zip(names, matrix):
233
+ print(f"{name:>{name_width}}" + "".join(f"{value:>{column_width}}" for value in row))