evonet 0.1.0.dev9__tar.gz → 0.1.0.dev11__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.dev11}/PKG-INFO +2 -1
  2. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/evonet/core.py +65 -5
  3. evonet-0.1.0.dev11/evonet/core_mit_plot_simple.py +645 -0
  4. evonet-0.1.0.dev11/evonet/serialization.py +171 -0
  5. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/evonet.egg-info/PKG-INFO +2 -1
  6. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/evonet.egg-info/SOURCES.txt +2 -0
  7. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/evonet.egg-info/requires.txt +1 -0
  8. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/pyproject.toml +3 -2
  9. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/LICENSE +0 -0
  10. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/README.md +0 -0
  11. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/evonet/__init__.py +0 -0
  12. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/evonet/activation.py +0 -0
  13. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/evonet/connection.py +0 -0
  14. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/evonet/enums.py +0 -0
  15. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/evonet/io.py +0 -0
  16. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/evonet/layer.py +0 -0
  17. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/evonet/mutation.py +0 -0
  18. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/evonet/neuron.py +0 -0
  19. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/evonet/utils.py +0 -0
  20. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/evonet/visualize.py +0 -0
  21. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/evonet.egg-info/dependency_links.txt +0 -0
  22. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/evonet.egg-info/top_level.txt +0 -0
  23. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/setup.cfg +0 -0
  24. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/tests/test_activation.py +0 -0
  25. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/tests/test_core.py +0 -0
  26. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/tests/test_nnet_io_and_forward.py +0 -0
  27. {evonet-0.1.0.dev9 → evonet-0.1.0.dev11}/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.dev11
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
@@ -170,14 +171,17 @@ class Nnet:
170
171
  if recurrent and not skip_connections:
171
172
  if RecurrentKind.DIRECT in recurrent:
172
173
  for n in new_neurons:
173
- if n.role == NeuronRole.HIDDEN:
174
- self.add_connection(
175
- n, n, weight=weight, conn_type=ConnectionType.RECURRENT
176
- )
174
+ if n.role != NeuronRole.HIDDEN:
175
+ continue # No recurrence on INPUT or OUTPUT
176
+ self.add_connection(
177
+ n, n, weight=weight, conn_type=ConnectionType.RECURRENT
178
+ )
177
179
 
178
180
  if RecurrentKind.LATERAL in recurrent:
179
181
  full_layer = list(self.layers[layer_idx].neurons)
180
182
  for src in full_layer:
183
+ if src != NeuronRole.HIDDEN:
184
+ continue # No recurrence on INPUT or OUTPUT
181
185
  for dst in new_neurons:
182
186
  if src is not dst:
183
187
  self.add_connection(
@@ -189,6 +193,8 @@ class Nnet:
189
193
 
190
194
  if RecurrentKind.INDIRECT in recurrent:
191
195
  for src in new_neurons:
196
+ if src != NeuronRole.HIDDEN:
197
+ continue # No recurrence on INPUT or OUTPUT
192
198
  for lower_layer in self.layers[1:layer_idx]:
193
199
  for dst in lower_layer.neurons:
194
200
  self.add_connection(
@@ -199,6 +205,8 @@ class Nnet:
199
205
  )
200
206
  for higher_layer in self.layers[layer_idx + 1 :]:
201
207
  for src in higher_layer.neurons:
208
+ if src != NeuronRole.HIDDEN:
209
+ continue # No recurrence on INPUT or OUTPUT
202
210
  for dst in new_neurons:
203
211
  self.add_connection(
204
212
  src,
@@ -329,7 +337,7 @@ class Nnet:
329
337
  f"{total_connections} connections "
330
338
  )
331
339
 
332
- def print_graph(
340
+ def plot(
333
341
  self,
334
342
  name: str,
335
343
  engine: str = "dot",
@@ -549,3 +557,55 @@ class Nnet:
549
557
 
550
558
  for b, n in zip(flat, targets):
551
559
  n.bias = float(b)
560
+
561
+ def save(self, path: str) -> None:
562
+ """
563
+ Save this network to a file.
564
+
565
+ The file format is chosen automatically based on the extension:
566
+ - .yaml / .yml --> YAML (human-readable, recommended)
567
+ - .json --> JSON (machine-friendly)
568
+
569
+ Args:
570
+ path (str): Output file path.
571
+ """
572
+
573
+ from . import serialization # local import to avoid circular import
574
+
575
+ suffix = Path(path).suffix.lower()
576
+ if suffix in (".yaml", ".yml"):
577
+ serialization.save_yaml(self, path)
578
+ elif suffix == ".json":
579
+ serialization.save_json(self, path)
580
+ else:
581
+ raise ValueError(
582
+ f"Unsupported file extension '{suffix}'. Use .yaml, .yml or .json"
583
+ )
584
+
585
+ @classmethod
586
+ def load(cls, path: str) -> "Nnet":
587
+ """
588
+ Load a network from a file.
589
+
590
+ The file format is chosen automatically based on the extension:
591
+ - .yaml / .yml --> YAML
592
+ - .json --> JSON
593
+
594
+ Args:
595
+ path (str): Path to the serialized network file.
596
+
597
+ Returns:
598
+ Nnet: The reconstructed network.
599
+ """
600
+
601
+ from . import serialization # local import to avoid circular import
602
+
603
+ suffix = Path(path).suffix.lower()
604
+ if suffix in (".yaml", ".yml"):
605
+ return serialization.load_yaml(path)
606
+ elif suffix == ".json":
607
+ return serialization.load_json(path)
608
+ else:
609
+ raise ValueError(
610
+ f"Unsupported file extension '{suffix}'. Use .yaml, .yml or .json"
611
+ )
@@ -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.dev11
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.dev11"
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