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.
- genetic_classifier/__init__.py +10 -0
- genetic_classifier/core.py +339 -0
- genetic_classifier/creation.py +47 -0
- genetic_classifier/crossover.py +53 -0
- genetic_classifier/evaluation.py +233 -0
- genetic_classifier/fitness.py +194 -0
- genetic_classifier/mutation.py +53 -0
- genetic_classifier/reading.py +100 -0
- genetic_classifier/selection.py +91 -0
- genetic_classifier/split.py +110 -0
- genetic_classifier/update.py +84 -0
- genetic_classifier-3.0.0.dist-info/METADATA +298 -0
- genetic_classifier-3.0.0.dist-info/RECORD +15 -0
- genetic_classifier-3.0.0.dist-info/WHEEL +4 -0
- genetic_classifier-3.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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))
|