evonet 0.1.0.dev9__tar.gz → 0.1.0.dev10__tar.gz

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.
Files changed (27) hide show
  1. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/PKG-INFO +2 -1
  2. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/evonet/core.py +54 -1
  3. evonet-0.1.0.dev10/evonet/core_mit_plot_simple.py +645 -0
  4. evonet-0.1.0.dev10/evonet/serialization.py +171 -0
  5. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/evonet.egg-info/PKG-INFO +2 -1
  6. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/evonet.egg-info/SOURCES.txt +2 -0
  7. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/evonet.egg-info/requires.txt +1 -0
  8. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/pyproject.toml +3 -2
  9. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/LICENSE +0 -0
  10. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/README.md +0 -0
  11. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/evonet/__init__.py +0 -0
  12. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/evonet/activation.py +0 -0
  13. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/evonet/connection.py +0 -0
  14. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/evonet/enums.py +0 -0
  15. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/evonet/io.py +0 -0
  16. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/evonet/layer.py +0 -0
  17. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/evonet/mutation.py +0 -0
  18. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/evonet/neuron.py +0 -0
  19. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/evonet/utils.py +0 -0
  20. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/evonet/visualize.py +0 -0
  21. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/evonet.egg-info/dependency_links.txt +0 -0
  22. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/evonet.egg-info/top_level.txt +0 -0
  23. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/setup.cfg +0 -0
  24. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/tests/test_activation.py +0 -0
  25. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/tests/test_core.py +0 -0
  26. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/tests/test_nnet_io_and_forward.py +0 -0
  27. {evonet-0.1.0.dev9 → evonet-0.1.0.dev10}/tests/test_recurrent_dynamics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evonet
3
- Version: 0.1.0.dev9
3
+ Version: 0.1.0.dev10
4
4
  Summary: Evolvable neural network core for integration with EvoLib
5
5
  Author-email: EvoLib <evolib@dismail.de>
6
6
  License: MIT License
@@ -39,6 +39,7 @@ Requires-Dist: pyyaml>=6.0
39
39
  Requires-Dist: pandas>=2.3.0
40
40
  Requires-Dist: pydantic<3.0,>=2.7
41
41
  Requires-Dist: graphviz>=0.20.1
42
+ Requires-Dist: matplotlib
42
43
  Provides-Extra: dev
43
44
  Requires-Dist: mypy; extra == "dev"
44
45
  Requires-Dist: types-PyYAML; extra == "dev"
@@ -9,6 +9,7 @@ export interfaces.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ from pathlib import Path
12
13
  from typing import Literal, Optional
13
14
 
14
15
  import graphviz
@@ -329,7 +330,7 @@ class Nnet:
329
330
  f"{total_connections} connections "
330
331
  )
331
332
 
332
- def print_graph(
333
+ def plot(
333
334
  self,
334
335
  name: str,
335
336
  engine: str = "dot",
@@ -549,3 +550,55 @@ class Nnet:
549
550
 
550
551
  for b, n in zip(flat, targets):
551
552
  n.bias = float(b)
553
+
554
+ def save(self, path: str) -> None:
555
+ """
556
+ Save this network to a file.
557
+
558
+ The file format is chosen automatically based on the extension:
559
+ - .yaml / .yml --> YAML (human-readable, recommended)
560
+ - .json --> JSON (machine-friendly)
561
+
562
+ Args:
563
+ path (str): Output file path.
564
+ """
565
+
566
+ from . import serialization # local import to avoid circular import
567
+
568
+ suffix = Path(path).suffix.lower()
569
+ if suffix in (".yaml", ".yml"):
570
+ serialization.save_yaml(self, path)
571
+ elif suffix == ".json":
572
+ serialization.save_json(self, path)
573
+ else:
574
+ raise ValueError(
575
+ f"Unsupported file extension '{suffix}'. Use .yaml, .yml or .json"
576
+ )
577
+
578
+ @classmethod
579
+ def load(cls, path: str) -> "Nnet":
580
+ """
581
+ Load a network from a file.
582
+
583
+ The file format is chosen automatically based on the extension:
584
+ - .yaml / .yml --> YAML
585
+ - .json --> JSON
586
+
587
+ Args:
588
+ path (str): Path to the serialized network file.
589
+
590
+ Returns:
591
+ Nnet: The reconstructed network.
592
+ """
593
+
594
+ from . import serialization # local import to avoid circular import
595
+
596
+ suffix = Path(path).suffix.lower()
597
+ if suffix in (".yaml", ".yml"):
598
+ return serialization.load_yaml(path)
599
+ elif suffix == ".json":
600
+ return serialization.load_json(path)
601
+ else:
602
+ raise ValueError(
603
+ f"Unsupported file extension '{suffix}'. Use .yaml, .yml or .json"
604
+ )
@@ -0,0 +1,645 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """
3
+ Core class for evolvable neural networks.
4
+
5
+ Manages neurons, layers, and connections with explicit topology. Supports forward passes
6
+ with optional recurrent connections across time steps, mutation/crossover hooks, and
7
+ export interfaces.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Literal, Optional
13
+
14
+ import graphviz
15
+ import numpy as np
16
+ import matplotlib.pyplot as plt
17
+
18
+ from evonet.activation import softmax as softmax_vec
19
+ from evonet.connection import Connection
20
+ from evonet.enums import ConnectionType, NeuronRole, RecurrentKind
21
+ from evonet.layer import Layer
22
+ from evonet.neuron import Neuron
23
+
24
+
25
+ class Nnet:
26
+ """
27
+ Evolvable neural network with explicit layered topology.
28
+
29
+ Attributes:
30
+ layers (list[Layer]): Ordered list of network layers.
31
+ """
32
+
33
+ def __init__(self) -> None:
34
+ self.layers: list[Layer] = []
35
+
36
+ @property
37
+ def num_weights(self) -> int:
38
+ """Number of connections in the network (no allocation)."""
39
+ return len(self.get_all_connections())
40
+
41
+ @property
42
+ def num_biases(self) -> int:
43
+ """
44
+ Return the number of trainable biases (excludes input neurons).
45
+
46
+ Inputs are feature holders and have no trainable bias in this design.
47
+ """
48
+ count = 0
49
+ for layer in self.layers:
50
+ for neuron in layer.neurons:
51
+ if neuron.role is not NeuronRole.INPUT:
52
+ count += 1
53
+ return count
54
+
55
+ @property
56
+ def num_params(self) -> int:
57
+ """Total parameter count = weights + biases."""
58
+ return self.num_weights + self.num_biases
59
+
60
+ def add_layer(self, count: int = 1) -> int:
61
+ """
62
+ Append one or more empty layers to the network.
63
+
64
+ Args:
65
+ count (int): Number of layers to add (must be > 0).
66
+
67
+ Returns:
68
+ int: Index of the last added layer.
69
+
70
+ Raises:
71
+ ValueError: If count is not positive.
72
+ """
73
+
74
+ if count <= 0:
75
+ raise ValueError("Number of layers must be greater then zero")
76
+
77
+ for _ in range(count):
78
+ self.layers.append(Layer())
79
+
80
+ return len(self.layers) - 1
81
+
82
+ def insert_layer(self, index: int) -> None:
83
+ """
84
+ Insert an empty layer at a given index.
85
+
86
+ Args:
87
+ index (int): Position to insert the new layer (0 = before input).
88
+
89
+ Raises:
90
+ ValueError: If index is out of bounds.
91
+ """
92
+
93
+ if not (0 <= index <= len(self.layers)):
94
+ raise ValueError(f"insert_layer: index {index} out of bounds.")
95
+ self.layers.insert(index, Layer())
96
+
97
+ def add_neuron(
98
+ self,
99
+ layer_idx: int | None = None,
100
+ activation: str = "tanh",
101
+ bias: float = 0.0,
102
+ label: str = "",
103
+ role: NeuronRole = NeuronRole.HIDDEN,
104
+ count: int = 1,
105
+ connection_init: Literal["random", "zero", "none"] = "random",
106
+ recurrent: Optional[set[RecurrentKind]] = None,
107
+ ) -> list[Neuron]:
108
+ """
109
+ Add one or more neurons to the network.
110
+
111
+ Args:
112
+ layer_idx: Target layer index. Defaults to last layer.
113
+ activation: Activation function name.
114
+ bias: Initial bias value.
115
+ label: Optional label.
116
+ role: Role of the neuron (INPUT, HIDDEN, OUTPUT).
117
+ count: Number of neurons to add (default: 1).
118
+ connection_init:
119
+ "random" – connect with random weights (feedforward + recurrent)
120
+ "zero" – connect with weight 0.0 (feedforward + recurrent)
121
+ "none" – do not create connections (feedforward + recurrent)
122
+ recurrent: Optional recurrent connection types.
123
+
124
+ Returns:
125
+ list[Neuron]: List of added neurons.
126
+ """
127
+ if layer_idx is None:
128
+ layer_idx = len(self.layers) - 1 # Add neuron to last layer
129
+
130
+ if layer_idx < 0:
131
+ raise ValueError(f"Layer index must be >= 0 (got {layer_idx})")
132
+ if layer_idx >= len(self.layers):
133
+ raise ValueError(f"Layer index out of bounds: {layer_idx}")
134
+
135
+ target_layer = self.layers[layer_idx]
136
+ new_neurons: list[Neuron] = []
137
+
138
+ # Create neurons without connections
139
+ for _ in range(count):
140
+ neuron = Neuron(activation=activation, bias=bias)
141
+ neuron.role = role
142
+ neuron.label = label
143
+ target_layer.neurons.append(neuron)
144
+ new_neurons.append(neuron)
145
+
146
+ # Weights based on init mode
147
+ weight_map = {"random": None, "zero": 0.0, "none": None}
148
+ if connection_init not in weight_map:
149
+ raise ValueError(f"Invalid connection_init: {connection_init}")
150
+ weight = weight_map[connection_init]
151
+
152
+ skip_connections = connection_init == "none"
153
+
154
+ # Connect to previous layer
155
+ if not skip_connections and layer_idx > 0:
156
+ for prev_neuron in self.layers[layer_idx - 1].neurons:
157
+ for n in new_neurons:
158
+ self.add_connection(prev_neuron, n, weight=weight)
159
+
160
+ # Connect to next layer (hidden only)
161
+ if (
162
+ not skip_connections
163
+ and role == NeuronRole.HIDDEN
164
+ and layer_idx < len(self.layers) - 1
165
+ ):
166
+ for next_neuron in self.layers[layer_idx + 1].neurons:
167
+ for n in new_neurons:
168
+ self.add_connection(n, next_neuron, weight=weight)
169
+
170
+ # Recurrent connections
171
+ if recurrent and not skip_connections:
172
+ if RecurrentKind.DIRECT in recurrent:
173
+ for n in new_neurons:
174
+ if n.role == NeuronRole.HIDDEN:
175
+ self.add_connection(
176
+ n, n, weight=weight, conn_type=ConnectionType.RECURRENT
177
+ )
178
+
179
+ if RecurrentKind.LATERAL in recurrent:
180
+ full_layer = list(self.layers[layer_idx].neurons)
181
+ for src in full_layer:
182
+ for dst in new_neurons:
183
+ if src is not dst:
184
+ self.add_connection(
185
+ src,
186
+ dst,
187
+ weight=weight,
188
+ conn_type=ConnectionType.RECURRENT,
189
+ )
190
+
191
+ if RecurrentKind.INDIRECT in recurrent:
192
+ for src in new_neurons:
193
+ for lower_layer in self.layers[1:layer_idx]:
194
+ for dst in lower_layer.neurons:
195
+ self.add_connection(
196
+ src,
197
+ dst,
198
+ weight=weight,
199
+ conn_type=ConnectionType.RECURRENT,
200
+ )
201
+ for higher_layer in self.layers[layer_idx + 1 :]:
202
+ for src in higher_layer.neurons:
203
+ for dst in new_neurons:
204
+ self.add_connection(
205
+ src,
206
+ dst,
207
+ weight=weight,
208
+ conn_type=ConnectionType.RECURRENT,
209
+ )
210
+
211
+ return new_neurons
212
+
213
+ def add_connection(
214
+ self,
215
+ source: Neuron,
216
+ target: Neuron,
217
+ weight: float | None = None,
218
+ conn_type: ConnectionType = ConnectionType.STANDARD,
219
+ ) -> None:
220
+ """
221
+ Create a directed connection between two neurons.
222
+
223
+ Args:
224
+ source (Neuron): Source neuron.
225
+ target (Neuron): Target neuron.
226
+ weight (float | None): Initial weight. If None, random value is used.
227
+ conn_type (ConnectionType): Type of connection (e.g. standard, recurrent).
228
+ """
229
+
230
+ if weight is None:
231
+ weight = np.random.randn() * 0.5
232
+
233
+ conn = Connection(source, target, weight=weight, conn_type=conn_type)
234
+ source.outgoing.append(conn)
235
+ target.incoming.append(conn)
236
+
237
+ def reset(self, full: bool = False) -> None:
238
+ """Reset all neurons (clears input, output, and caches)."""
239
+ for layer in self.layers:
240
+ for neuron in layer.neurons:
241
+ neuron.reset(full=full)
242
+
243
+ def calc(self, input_values: list[float]) -> list[float]:
244
+ """
245
+ Perform a forward pass through the network.
246
+
247
+ Args:
248
+ input_values (list[float]): Input vector (must match input layer size).
249
+
250
+ Returns:
251
+ list[float]: Output values from the last layer.
252
+
253
+ Raises:
254
+ AssertionError: If input size does not match input layer.
255
+ """
256
+
257
+ # Save LAST OUTPUT
258
+ for layer in self.layers:
259
+ for neuron in layer.neurons:
260
+ neuron.last_output = neuron.output
261
+
262
+ self.reset()
263
+
264
+ # Set inputs
265
+ input_layer = self.layers[0]
266
+ assert len(input_layer.neurons) == len(input_values)
267
+ for i, n in enumerate(input_layer.neurons):
268
+ n.input = float(input_values[i])
269
+
270
+ # Preload recurrent contributions from previous time step (last_output)
271
+ for layer in self.layers:
272
+ for n in layer.neurons:
273
+ for c in n.incoming:
274
+ if c.type is ConnectionType.RECURRENT:
275
+ c.target.input += c.weight * c.source.last_output
276
+
277
+ # Feed-forward by layers: activate first, then propagate non-recurrent edges
278
+ for layer in self.layers:
279
+
280
+ # Apply softmax to all neurons with activation_name == "softmax"
281
+ softmax_neurons = [
282
+ n for n in layer.neurons if n.activation_name == "softmax"
283
+ ]
284
+ if softmax_neurons:
285
+ if len(softmax_neurons) >= 2:
286
+ # Normal softmax behaviour
287
+ totals = [n.input + n.bias for n in softmax_neurons]
288
+ probabilities = softmax_vec(totals)
289
+ for n, p in zip(softmax_neurons, probabilities):
290
+ n.output = float(p)
291
+ else:
292
+ # Fallback: single softmax neuron acts like identity
293
+ n = softmax_neurons[0]
294
+ n.output = n.input + n.bias
295
+
296
+ # Activate all neurons in this layer
297
+ for n in layer.neurons:
298
+ if n.activation_name != "softmax":
299
+ total = n.input + n.bias
300
+ n.output = n.activation(total)
301
+
302
+ # Propagate to targets (exclude recurrent edges)
303
+ for n in layer.neurons:
304
+ for c in n.outgoing:
305
+ if c.type is not ConnectionType.RECURRENT:
306
+ c.target.input += c.weight * n.output
307
+
308
+ return [n.output for n in self.layers[-1].neurons]
309
+
310
+ def get_all_neurons(self) -> list[Neuron]:
311
+ """Return all neurons in all layers (flattened)."""
312
+ return [n for layer in self.layers for n in layer.neurons]
313
+
314
+ def get_all_connections(self) -> list[Connection]:
315
+ """Return all outgoing connections in the network."""
316
+ return [c for n in self.get_all_neurons() for c in n.outgoing]
317
+
318
+ def __repr__(self) -> str:
319
+ total_neurons = sum(len(layer.neurons) for layer in self.layers)
320
+ input_neurons = len(self.layers[0].neurons) if self.layers else 0
321
+ output_neurons = len(self.layers[-1].neurons) if len(self.layers) > 1 else 0
322
+ hidden_neurons = total_neurons - input_neurons - output_neurons
323
+
324
+ total_connections = len(self.get_all_connections())
325
+
326
+ return (
327
+ f"<Nnet | {len(self.layers)} layers, "
328
+ f"{total_neurons} neurons (I:{input_neurons} H:{hidden_neurons} "
329
+ f"O:{output_neurons}), "
330
+ f"{total_connections} connections "
331
+ )
332
+
333
+ def print_graph(
334
+ self,
335
+ name: str,
336
+ engine: str = "dot",
337
+ labels_on: bool = True,
338
+ colors_on: bool = True,
339
+ thickness_on: bool = False,
340
+ fillcolors_on: bool = False,
341
+ ) -> None:
342
+ """
343
+ Render a visual representation of the network using Graphviz.
344
+
345
+ Args:
346
+ name (str): Output file name (without extension).
347
+ engine (str): Graphviz layout engine (e.g., 'dot', 'neato').
348
+ labels_on (bool): Whether to show edge weights as labels.
349
+ colors_on (bool): Whether to color edges by sign.
350
+ thickness_on (bool): Whether to scale edge thickness by weight.
351
+ fillcolors_on (bool): Whether to color neurons by role.
352
+ """
353
+
354
+ if not self.layers:
355
+ print("No layers to visualize.")
356
+ return
357
+
358
+ dot = graphviz.Digraph(name=name, format="png", engine=engine)
359
+ dot.graph_attr.update(
360
+ bgcolor="white",
361
+ rankdir="LR",
362
+ overlap="prism",
363
+ sep="15",
364
+ ratio="fill",
365
+ splines="spline",
366
+ size="6.68,5!",
367
+ dpi="600",
368
+ )
369
+ dot.node_attr.update(
370
+ shape="circle", style="filled", fixedsize="shape", width="1.8"
371
+ )
372
+ dot.edge_attr.update(arrowsize="0.8")
373
+
374
+ # Add neurons with coordinates (x = layer, y = index)
375
+ for layer_idx, layer in enumerate(self.layers):
376
+ for neuron_idx, neuron in enumerate(layer.neurons):
377
+ if neuron.role.name == "INPUT":
378
+ fillcolor = "lightblue" if fillcolors_on else "white"
379
+ elif neuron.role.name == "OUTPUT":
380
+ fillcolor = "orange" if fillcolors_on else "white"
381
+ else:
382
+ fillcolor = "lightgreen" if fillcolors_on else "white"
383
+
384
+ label = (
385
+ f"{neuron.label or neuron.role.name}({layer_idx})\n"
386
+ f"In: {neuron.input:.3f}\n"
387
+ f"Out: {neuron.output:.3f}\n"
388
+ f"LastOut: {neuron.last_output:.3f}\n"
389
+ f"Bias: {neuron.bias:.3f}\n"
390
+ f"{neuron.activation_name}"
391
+ )
392
+
393
+ pos = f"{layer_idx},{-neuron_idx}!"
394
+ dot.node(
395
+ name=neuron.id,
396
+ label=label,
397
+ fillcolor=fillcolor,
398
+ pos=pos,
399
+ )
400
+
401
+ # Add edges
402
+ for conn in self.get_all_connections():
403
+ label = f"{conn.weight:.2f}" if labels_on else ""
404
+ color = (
405
+ "green"
406
+ if colors_on and conn.weight >= 0
407
+ else "red" if colors_on else "black"
408
+ )
409
+ penwidth = (
410
+ str(max(1, min(5, abs(conn.weight * 5)))) if thickness_on else "1"
411
+ )
412
+ style = "dashed" if conn.type.name == "RECURRENT" else "solid"
413
+
414
+ dot.edge(
415
+ conn.source.id,
416
+ conn.target.id,
417
+ label=label,
418
+ color=color,
419
+ penwidth=penwidth,
420
+ style=style,
421
+ )
422
+
423
+ dot.render(name, cleanup=True)
424
+
425
+ def plot_simple(
426
+ net,
427
+ show_weights: bool = False,
428
+ show_values: bool = True,
429
+ path: str | None = None,
430
+ ) -> None:
431
+ """
432
+ Visualize the network in a simple layered layout using Matplotlib.
433
+
434
+ Args:
435
+ net: The Nnet object to visualize.
436
+ show_weights (bool): If True, draw weight values on edges.
437
+ show_values (bool): If True, neuron output values are shown,
438
+ else neuron indices.
439
+ path (str | None): If given, save the plot to this path instead
440
+ of displaying.
441
+ """
442
+
443
+ fig, ax = plt.subplots(figsize=(8, 6))
444
+ ax.set_aspect("equal")
445
+ ax.axis("off")
446
+
447
+ neuron_positions: dict = {}
448
+
449
+ # Place neurons
450
+ for layer_idx, layer in enumerate(net.layers):
451
+ y_positions = list(range(len(layer.neurons)))
452
+ offset = (
453
+ max(len(layer.neurons) for layer in net.layers) - len(y_positions)
454
+ ) / 2.0
455
+
456
+ for neuron_idx, neuron in enumerate(layer.neurons):
457
+ x, y = layer_idx, neuron_idx + offset
458
+ neuron_positions[neuron] = (x, y)
459
+
460
+ ax.scatter(
461
+ x,
462
+ y,
463
+ c="royalblue",
464
+ s=400,
465
+ zorder=3,
466
+ edgecolors="white",
467
+ linewidths=1.5,
468
+ )
469
+
470
+ label = f"{neuron.output:.2f}" if show_values else str(neuron_idx)
471
+ ax.text(
472
+ x,
473
+ y,
474
+ label,
475
+ ha="center",
476
+ va="center",
477
+ color="white",
478
+ fontsize=9,
479
+ zorder=4,
480
+ )
481
+
482
+ # Draw connections
483
+ for layer in net.layers[1:]:
484
+ for neuron in layer.neurons:
485
+ x2, y2 = neuron_positions[neuron]
486
+ for conn in neuron.incoming:
487
+ x1, y1 = neuron_positions[conn.source]
488
+ style = "--" if getattr(conn, "recurrent", False) else "-"
489
+ color = "crimson" if getattr(conn, "recurrent", False) else "gray"
490
+ ax.plot(
491
+ [x1, x2],
492
+ [y1, y2],
493
+ color=color,
494
+ linestyle=style,
495
+ alpha=0.7,
496
+ zorder=1,
497
+ )
498
+
499
+ if show_weights:
500
+ xm, ym = (x1 + x2) / 2, (y1 + y2) / 2
501
+ ax.text(
502
+ xm,
503
+ ym,
504
+ f"{conn.weight:.2f}",
505
+ fontsize=7,
506
+ color="black",
507
+ alpha=0.6,
508
+ )
509
+
510
+ plt.tight_layout()
511
+
512
+ if path:
513
+ plt.savefig(path, dpi=150)
514
+ plt.close(fig)
515
+ else:
516
+ plt.show()
517
+
518
+ def _build_index_map(self) -> dict[Neuron, tuple[int, int]]:
519
+ """Build a mapping from neuron -> (layer_idx, neuron_idx) for O(1) lookups."""
520
+ index_map: dict[Neuron, tuple[int, int]] = {}
521
+ for layer_idx, layer in enumerate(self.layers):
522
+ for neuron_idx, neuron in enumerate(layer.neurons):
523
+ index_map[neuron] = (layer_idx, neuron_idx)
524
+ return index_map
525
+
526
+ def get_weights(self) -> np.ndarray:
527
+ """
528
+ Return all connection weights as a flat vector in a deterministic order.
529
+
530
+ Returns:
531
+ np.ndarray: 1D array of connection weights.
532
+
533
+ Order key:
534
+ (src_layer_idx, src_neuron_idx, dst_layer_idx,
535
+ dst_neuron_idx, connection_type)
536
+ """
537
+ conns = self.get_all_connections()
538
+ if not conns:
539
+ return np.empty(0, dtype=float)
540
+
541
+ index_map = self._build_index_map()
542
+
543
+ def sort_key(c: Connection) -> tuple[int, int, int, int, int]:
544
+ src_layer_idx, src_neuron_idx = index_map[c.source]
545
+ dst_layer_idx, dst_neuron_idx = index_map[c.target]
546
+ return (
547
+ src_layer_idx,
548
+ src_neuron_idx,
549
+ dst_layer_idx,
550
+ dst_neuron_idx,
551
+ int(c.type.value),
552
+ )
553
+
554
+ conns_sorted = sorted(conns, key=sort_key)
555
+ return np.array([c.weight for c in conns_sorted], dtype=float)
556
+
557
+ def set_weights(self, flat: np.ndarray) -> None:
558
+ """
559
+ Set all connection weights from a flat vector using the same deterministic order
560
+ as `get_weights()`.
561
+
562
+ Args:
563
+ flat (np.ndarray): Flat array of weights (must match number of connections).
564
+
565
+ Raises:
566
+ ValueError: If the length of the array does not match.
567
+ """
568
+ flat = np.asarray(flat, dtype=float).ravel()
569
+
570
+ conns = self.get_all_connections()
571
+ if not conns and flat.size == 0:
572
+ return
573
+
574
+ index_map = self._build_index_map()
575
+
576
+ def sort_key(c: Connection) -> tuple[int, int, int, int, int]:
577
+ src_layer_idx, src_neuron_idx = index_map[c.source]
578
+ dst_layer_idx, dst_neuron_idx = index_map[c.target]
579
+ return (
580
+ src_layer_idx,
581
+ src_neuron_idx,
582
+ dst_layer_idx,
583
+ dst_neuron_idx,
584
+ int(c.type.value),
585
+ )
586
+
587
+ conns_sorted = sorted(conns, key=sort_key)
588
+
589
+ if flat.size != len(conns_sorted):
590
+ raise ValueError(
591
+ f"Length mismatch for weights: expected {len(conns_sorted)}, "
592
+ f"got {flat.size}."
593
+ )
594
+
595
+ for weight_value, conn in zip(flat, conns_sorted):
596
+ conn.weight = float(weight_value)
597
+
598
+ def get_biases(self) -> np.ndarray:
599
+ """
600
+ Return all trainable biases as a flat vector (excluding input neurons).
601
+
602
+ Returns:
603
+ np.ndarray: Bias vector.
604
+
605
+ Order:
606
+ (layer_index, neuron_index) over all non-input neurons.
607
+ """
608
+
609
+ if not self.layers:
610
+ return np.empty(0, dtype=float)
611
+
612
+ biases: list[float] = []
613
+ for _, layer in enumerate(self.layers):
614
+ for _, neuron in enumerate(layer.neurons):
615
+ if neuron.role is not NeuronRole.INPUT:
616
+ biases.append(neuron.bias)
617
+ return np.asarray(biases, dtype=float)
618
+
619
+ def set_biases(self, flat: np.ndarray) -> None:
620
+ """
621
+ Set all neuron biases (excluding input neurons) from a flat vector using the
622
+ same ordering as `get_biases()`.
623
+
624
+ Args:
625
+ flat (np.ndarray): Bias values in deterministic order.
626
+
627
+ Raises:
628
+ ValueError: If length does not match the number of biases.
629
+ """
630
+ flat = np.asarray(flat, dtype=float).ravel()
631
+
632
+ # Collect non-input neurons in deterministic order
633
+ targets: list[Neuron] = []
634
+ for _, layer in enumerate(self.layers):
635
+ for _, neuron in enumerate(layer.neurons):
636
+ if neuron.role is not NeuronRole.INPUT:
637
+ targets.append(neuron)
638
+
639
+ if flat.size != len(targets):
640
+ raise ValueError(
641
+ f"Length mismatch for biases: expected {len(targets)}, got {flat.size}."
642
+ )
643
+
644
+ for b, n in zip(flat, targets):
645
+ n.bias = float(b)
@@ -0,0 +1,171 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """
3
+ Serialization utilities for EvoNet networks.
4
+
5
+ This module provides functions to save and load EvoNet networks
6
+ to and from human-readable YAML files (default) or JSON files.
7
+ The serialization preserves the full network topology:
8
+ - Layers (index, role, label)
9
+ - Neurons (id, activation, bias, role, label)
10
+ - Connections (src, dst, weight, recurrent)
11
+
12
+ YAML is recommended for readability and manual editing.
13
+ JSON is provided as a secondary option for interoperability.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from typing import Any
20
+
21
+ import yaml
22
+
23
+ from evonet.core import Neuron, Nnet
24
+ from evonet.enums import ConnectionType, NeuronRole
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Helper
28
+ # ---------------------------------------------------------------------------
29
+
30
+
31
+ def to_dict(net: Nnet) -> dict[str, Any]:
32
+ """
33
+ Convert an EvoNet network into a serializable dictionary.
34
+
35
+ Args:
36
+ net (Nnet): The network to convert.
37
+
38
+ Returns:
39
+ dict[str, Any]: A nested dictionary representation.
40
+ """
41
+ return {
42
+ "layers": [
43
+ {
44
+ "index": i,
45
+ "neurons": [
46
+ {
47
+ "id": n.id,
48
+ "activation": n.activation_name,
49
+ "bias": n.bias,
50
+ "role": n.role.name,
51
+ "label": n.label,
52
+ "incoming": [
53
+ {
54
+ "source": c.source.id,
55
+ "target": c.target.id,
56
+ "weight": c.weight,
57
+ "type": c.type.name, # store enum as string
58
+ }
59
+ for c in n.incoming
60
+ ],
61
+ }
62
+ for n in layer.neurons
63
+ ],
64
+ }
65
+ for i, layer in enumerate(net.layers)
66
+ ]
67
+ }
68
+
69
+
70
+ def from_dict(data: dict[str, Any]) -> Nnet:
71
+ """Reconstruct a network from a dictionary created by `to_dict`."""
72
+ net = Nnet()
73
+ neuron_map: dict[str, Neuron] = {}
74
+
75
+ # Rebuild layers and neurons
76
+ for layer_info in data["layers"]:
77
+ net.add_layer()
78
+ for n_info in layer_info["neurons"]:
79
+ n = net.add_neuron(
80
+ activation=n_info["activation"],
81
+ bias=n_info["bias"],
82
+ role=NeuronRole[n_info["role"]],
83
+ label=n_info.get("label", ""),
84
+ connection_init="none",
85
+ )[0]
86
+ n.id = n_info["id"]
87
+ neuron_map[n.id] = n
88
+
89
+ # Rebuild connections
90
+ for layer_info in data["layers"]:
91
+ for n_info in layer_info["neurons"]:
92
+ for c_info in n_info["incoming"]:
93
+ src = neuron_map[c_info["source"]]
94
+ dst = neuron_map[c_info["target"]]
95
+ net.add_connection(
96
+ src,
97
+ dst,
98
+ weight=c_info["weight"],
99
+ conn_type=ConnectionType[c_info["type"]],
100
+ )
101
+
102
+ return net
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # YAML interface
107
+ # ---------------------------------------------------------------------------
108
+
109
+
110
+ def save_yaml(net: Nnet, path: str) -> None:
111
+ """
112
+ Save a network to a YAML file (human-readable).
113
+
114
+ Args:
115
+ net (Nnet): The network to save.
116
+ path (str): Output file path.
117
+ """
118
+ with open(path, "w", encoding="utf-8") as f:
119
+ yaml.safe_dump(
120
+ to_dict(net),
121
+ f,
122
+ sort_keys=False, # preserve order for readability
123
+ default_flow_style=False, # block style (YAML best practice)
124
+ )
125
+
126
+
127
+ def load_yaml(path: str) -> Nnet:
128
+ """
129
+ Load a network from a YAML file.
130
+
131
+ Args:
132
+ path (str): Path to the YAML file.
133
+
134
+ Returns:
135
+ Nnet: Reconstructed network.
136
+ """
137
+ with open(path, "r", encoding="utf-8") as f:
138
+ data = yaml.safe_load(f)
139
+ return from_dict(data)
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # JSON interface
144
+ # ---------------------------------------------------------------------------
145
+
146
+
147
+ def save_json(net: Nnet, path: str) -> None:
148
+ """
149
+ Save a network to a JSON file.
150
+
151
+ Args:
152
+ net (Nnet): The network to save.
153
+ path (str): Output file path.
154
+ """
155
+ with open(path, "w", encoding="utf-8") as f:
156
+ json.dump(to_dict(net), f, indent=2)
157
+
158
+
159
+ def load_json(path: str) -> Nnet:
160
+ """
161
+ Load a network from a JSON file.
162
+
163
+ Args:
164
+ path (str): Path to the JSON file.
165
+
166
+ Returns:
167
+ Nnet: Reconstructed network.
168
+ """
169
+ with open(path, "r", encoding="utf-8") as f:
170
+ data = json.load(f)
171
+ return from_dict(data)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evonet
3
- Version: 0.1.0.dev9
3
+ Version: 0.1.0.dev10
4
4
  Summary: Evolvable neural network core for integration with EvoLib
5
5
  Author-email: EvoLib <evolib@dismail.de>
6
6
  License: MIT License
@@ -39,6 +39,7 @@ Requires-Dist: pyyaml>=6.0
39
39
  Requires-Dist: pandas>=2.3.0
40
40
  Requires-Dist: pydantic<3.0,>=2.7
41
41
  Requires-Dist: graphviz>=0.20.1
42
+ Requires-Dist: matplotlib
42
43
  Provides-Extra: dev
43
44
  Requires-Dist: mypy; extra == "dev"
44
45
  Requires-Dist: types-PyYAML; extra == "dev"
@@ -6,11 +6,13 @@ evonet/__init__.py
6
6
  evonet/activation.py
7
7
  evonet/connection.py
8
8
  evonet/core.py
9
+ evonet/core_mit_plot_simple.py
9
10
  evonet/enums.py
10
11
  evonet/io.py
11
12
  evonet/layer.py
12
13
  evonet/mutation.py
13
14
  evonet/neuron.py
15
+ evonet/serialization.py
14
16
  evonet/utils.py
15
17
  evonet/visualize.py
16
18
  evonet.egg-info/PKG-INFO
@@ -3,6 +3,7 @@ pyyaml>=6.0
3
3
  pandas>=2.3.0
4
4
  pydantic<3.0,>=2.7
5
5
  graphviz>=0.20.1
6
+ matplotlib
6
7
 
7
8
  [dev]
8
9
  mypy
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "evonet"
7
- version = "0.1.0.dev9"
7
+ version = "0.1.0.dev10"
8
8
  description = "Evolvable neural network core for integration with EvoLib"
9
9
  authors = [
10
10
  { name = "EvoLib", email = "evolib@dismail.de" }
@@ -17,7 +17,8 @@ dependencies = [
17
17
  "pyyaml >=6.0",
18
18
  "pandas >=2.3.0",
19
19
  "pydantic >= 2.7,<3.0",
20
- "graphviz>=0.20.1"
20
+ "graphviz>=0.20.1",
21
+ "matplotlib"
21
22
  ]
22
23
 
23
24
  classifiers = [
File without changes
File without changes
File without changes
File without changes