Files
stroblmeandClaude Opus 5 60d7ec81c0 Rename the import package app to fluksio
A wheel whose top-level module is `app` collides with anything else in a
user's venv, so the package that is about to be published takes the name
it is published under. Only the Python package moves; the repo, the
Docker WORKDIR and the compose project keep theirs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 21:48:05 +02:00

101 lines
3.3 KiB
Python

"""A small perceptron node, kept as a worked example of numeric logic."""
from __future__ import annotations
import logging
import threading
from collections.abc import Iterable
from typing import Any
import numpy as np
from pydantic import BaseModel, ConfigDict
from fluksio.flow.messages import MessageSpec
from fluksio.flow.nodes.base import Node
logger = logging.getLogger(__name__)
class MLPNode(Node):
"""
Multi-Layer Perceptron node for neural network processing in pipelines.
This node implements a simple single-layer neural network that applies
weights and biases to input values. Weights and biases are randomly
initialized using the provided random number generator.
The computation follows the standard neural network formula:
output = weights @ inputs + biases
:param requires: Input messages consumed by this node.
:type requires: MessageSpec | list[MessageSpec]
:param provides: Output messages produced by this node.
:type provides: MessageSpec | list[MessageSpec]
:param params: Parameters dict containing:
- ``rng`` (numpy.random.Generator): Random number generator for weight initialization
- Additional node parameters
:type params: dict
:param name: Name for this node.
:type name: str
:example:
>>> import numpy as np
>>> rng = np.random.default_rng(seed=42)
>>> mlp = MLPNode(
... requires=[MessageSpec(name="input1", dtype=DType.FLOAT), MessageSpec(name="input2", dtype=DType.FLOAT)],
... provides=[MessageSpec(name="output", dtype=DType.FLOAT)],
... params={"rng": rng},
... name="mlp_layer1",
... )
"""
class Params(BaseModel):
"""Weights are drawn from ``seed``, so a node reloads identically."""
model_config = ConfigDict(extra="allow")
seed: int = 0
def __init__(
self,
requires: MessageSpec | Iterable[MessageSpec] = (),
provides: MessageSpec | Iterable[MessageSpec] = (),
params: dict[str, Any] | None = None,
name: str | None = None,
):
super().__init__(
self._forward,
requires=requires,
provides=provides,
params=params,
name=name or "mlp",
)
cfg = self.Params.model_validate(self.params)
rng = np.random.default_rng(seed=cfg.seed)
num_inputs = max(1, len(self.input_ports))
num_outputs = max(1, len(self.output_ports))
self.weights = rng.normal(loc=1, size=(num_outputs, num_inputs))
self.biases = rng.normal(loc=0, size=(num_outputs,))
def _forward(
self, params: dict[str, Any], **kwargs: float
) -> dict[str, Any] | None:
"""Apply ``weights @ inputs + biases`` to the incoming values."""
if not self.output_ports:
return None
logger.info(
"Executing MLP node in thread %s: %s",
threading.current_thread().name,
self.id,
)
if kwargs:
input_array = np.array([float(v) for v in kwargs.values()])
else:
input_array = np.array([1.0]) # Bias only, for source nodes.
outputs = np.dot(self.weights, input_array) + self.biases
return {p.port: float(outputs[i]) for i, p in enumerate(self.output_ports)}