How to: Create and Use a ModelNode#
A ModelNode is a computational node that wraps a machine-learning model for use within a ModelGraph. It:
Wraps a backend-specific model (PyTorch, TensorFlow, or scikit-learn) as a
BaseModelReceives data from a single upstream source (
FeatureSetor anotherModelNode)Optionally holds an
Optimizerfor standalone trainingIs composed into a
ModelGraph, which is then used in anExperiment
Note: Users typically interact with
Experimentat the top level.ModelNodeis the building block thatModelGraphorchestrates. This guide covers the fullModelNodeAPI for users who need fine-grained control.
This notebook covers:
import numpy as np
import torch
from modularml import Experiment, FeatureSet, ModelNode, Optimizer
# Note that we don't need to explicitly create an Experiment right away
# We do it here so we can disable the warning raise when creating multiple
# nodes with the same name (`registration_policy` is what controls this).
exp = Experiment(label="create_modelnode", registration_policy="overwrite")
---------------------------------------------------------------------------
ModuleNotFoundError Traceback (most recent call last)
Cell In[1], line 2
1 import numpy as np
----> 2 import torch
4 from modularml import Experiment, FeatureSet, ModelNode, Optimizer
6 # Note that we don't need to explicitly create an Experiment right away
7 # We do it here so we can disable the warning raise when creating multiple
8 # nodes with the same name (`registration_policy` is what controls this).
ModuleNotFoundError: No module named 'torch'
We’ll use a simple synthetic dataset throughout this notebook: 500 samples of a 10-point voltage signal with a scalar state-of-health (SOH) target.
rng = np.random.default_rng(42)
fs = FeatureSet.from_dict(
label="SensorData",
data={
"voltage": list(rng.standard_normal((500, 10))),
"soh": list(rng.standard_normal((500, 1))),
},
feature_keys="voltage",
target_keys="soh",
)
print(fs)
Since FeatureSet can contains more columns than wanted for a certain models inputs, we need to specify which columns are intended to by input to the model.
This is done with the .reference() method on FeatureSets.
Going forward, our models will be trained on only the voltage feature, and estimate only the soh target.
fs_ref = fs.reference(features="voltage", targets="soh")
print(fs_ref)
The Model Hierarchy#
Before creating a ModelNode, it helps to understand the model abstraction layers:
BaseModel (abstract)
├── TorchBaseModel # Base for built-in PyTorch models
│ ├── SequentialMLP # Built-in MLP
│ └── SequentialCNN # Built-in 1D CNN
├── TorchModelWrapper # Wraps any torch.nn.Module
├── TensorflowModelWrapper # Wraps any tf.keras.Model
└── ScikitModelWrapper # Wraps any sklearn.BaseEstimator
All models used in a ModelNode must conform to the BaseModel interface. You can either:
Use a built-in model (e.g.,
SequentialMLP)Wrap your own model with
TorchModelWrapper,TensorflowModelWrapper, orScikitModelWrapperPass a raw model directly — it will be auto-wrapped via
wrap_model()
Built-In Models#
ModularML provides ready-to-use model architectures in modularml.models.
There are currently only built-in models using the PyTorch backend modularml.models.torch.
More will be added soon.
These built in models inherit from BaseModel and support lazy shape inference; you can provide shapes at construction time or let ModelGraph.build() infer them automatically.
SequentialMLP#
A configurable multi-layer perceptron. Inputs are flattened, passed through n_layers
fully-connected layers with activation and optional dropout, then reshaped to output_shape.
Parameter |
Type |
Default |
Description |
|---|---|---|---|
|
|
|
Input shape (no batch dim). Inferred at build if |
|
|
|
Output shape (no batch dim). Inferred at build if |
|
|
|
Number of linear layers. |
|
|
|
Hidden units per layer. |
|
|
|
Activation function ( |
|
|
|
Dropout rate (0 = no dropout). |
from modularml.models.torch import SequentialMLP
# Option A: Provide both shapes up front (builds immediately)
mlp_eager = SequentialMLP(
input_shape=(1, 10),
output_shape=(1, 1),
n_layers=3,
hidden_dim=64,
activation="gelu",
dropout=0.1,
)
print(f"Eager MLP built: {mlp_eager.is_built}")
print(f" input_shape: {mlp_eager.input_shape}")
print(f" output_shape: {mlp_eager.output_shape}")
# Option B: Defer shapes (lazy build - ModelGraph.build() will fill them in)
mlp_lazy = SequentialMLP(
output_shape=(1, 1),
n_layers=2,
hidden_dim=32,
)
print(f"Lazy MLP built: {mlp_lazy.is_built}")
print(f" input_shape: {mlp_lazy.input_shape}")
print(f" output_shape: {mlp_lazy.output_shape}")
SequentialCNN#
A 1D convolutional network.
Stacks Conv1d layers with optional pooling, dropout, and a final linear projection to output_shape.
Parameter |
Type |
Default |
Description |
|---|---|---|---|
|
|
|
Input shape as |
|
|
|
Output shape (no batch dim). |
|
|
|
Number of Conv1d layers. |
|
|
|
Output channels per Conv1d layer. |
|
|
|
Convolution kernel size. |
|
|
|
Convolution padding. |
|
|
|
Convolution stride. |
|
|
|
Activation function. |
|
|
|
Dropout rate. |
|
|
|
MaxPool1d kernel size (1 = no pooling). |
|
|
|
Whether to flatten and project to output shape. |
from modularml.models.torch import SequentialCNN
cnn = SequentialCNN(
input_shape=(1, 10), # 1 channel, 10-length signal
output_shape=(1, 1),
n_layers=2,
hidden_dim=16,
kernel_size=3,
pooling=2,
)
print(f"CNN built: {cnn.is_built}")
print(f" input_shape: {cnn.input_shape}")
print(f" output_shape: {cnn.output_shape}")
Wrapping Custom PyTorch Models#
For models not provided by ModularML, use TorchModelWrapper to wrap any torch.nn.Module.
Wrapping an Instantiated Model#
If you already have a constructed torch.nn.Module, pass it directly. The wrapper
validates input/output shapes with a dummy forward pass during build().
from modularml.core.models import TorchModelWrapper
# Define a custom PyTorch model
class MyEncoder(torch.nn.Module):
def __init__(self, in_features, out_features):
super().__init__()
# Storing constructor args as same-named attributes
# allows TorchModelWrapper to auto-infer them for serialization.
self.in_features = in_features
self.out_features = out_features
self.fc = torch.nn.Linear(in_features, out_features)
def forward(self, x):
return self.fc(x)
# Wrap an already-instantiated model
raw_model = MyEncoder(in_features=10, out_features=4)
wrapped = TorchModelWrapper(model=raw_model)
print(f"Wrapped model built: {wrapped.is_built}")
print(f" backend: {wrapped.backend}")
# Build to validate shapes
wrapped.build(input_shape=(10,), output_shape=(4,))
print(f"After build: {wrapped.is_built}")
print(f" input_shape: {wrapped.input_shape}")
print(f" output_shape: {wrapped.output_shape}")
Note that custom models cannot be serialized unless they are defined in a separate Python file.
Example use case:
from my_scipt import MyModel
model = MyModel(...)
More details on this are provided in the [Serialization]serialization section.
Lazy Construction from a Class#
If you want ModelGraph.build() to handle instantiation (injecting the correct
input_shape and output_shape), pass a class and kwargs instead.
lazy_wrapped = TorchModelWrapper(
model_class=MyEncoder,
model_kwargs={"in_features": 10, "out_features": 4},
)
print(f"Lazy wrapped built: {lazy_wrapped.is_built}")
# Build later (or let ModelGraph do it)
lazy_wrapped.build(input_shape=(10,), output_shape=(4,))
print(f"After build: {lazy_wrapped.is_built}")
print(f" output_shape: {lazy_wrapped.output_shape}")
Injecting Shapes into Custom Constructors#
By default, TorchModelWrapper injects the inferred input_shape and output_shape
into your model class constructor during lazy build. If your constructor uses different
parameter names, specify them with inject_input_shape_as and inject_output_shape_as.
class CustomModel(torch.nn.Module):
"""A model whose constructor uses non-standard shape parameter names."""
def __init__(self, in_shape, out_shape):
super().__init__()
self.in_shape = in_shape
self.out_shape = out_shape
self.fc = torch.nn.Linear(int(np.prod(in_shape)), int(np.prod(out_shape)))
def forward(self, x):
x = x.view(x.size(0), -1)
x = self.fc(x)
return x.view(x.size(0), *self.out_shape)
# Tell the wrapper to inject shapes using your parameter names
custom_wrapped = TorchModelWrapper(
model_class=CustomModel,
model_kwargs={},
inject_input_shape_as="in_shape",
inject_output_shape_as="out_shape",
)
custom_wrapped.build(input_shape=(10,), output_shape=(4,))
print(f"Custom wrapped output_shape: {custom_wrapped.output_shape}")
Auto-Wrapping with wrap_model()#
When you pass a raw torch.nn.Module directly to ModelNode, it is automatically
wrapped via wrap_model(). This is the simplest path but offers less control over
serialization and shape injection.
from modularml.core.models import wrap_model
raw_module = MyEncoder(in_features=10, out_features=4)
auto_wrapped = wrap_model(raw_module)
print(f"Type: {type(auto_wrapped).__name__}")
print(f"Backend: {auto_wrapped.backend}")
Scikit-Learn Models#
The ScikitModelWrapper wraps any sklearn.base.BaseEstimator for use in a ModelNode.
It supports both batch-fit models (e.g., RandomForestRegressor) and incremental models
(e.g., SGDRegressor) via the training_mode parameter.
Parameter |
Type |
Default |
Description |
|---|---|---|---|
|
|
(required) |
A scikit-learn estimator instance. |
|
|
|
|
|
|
|
|
|
|
|
Extra kwargs passed to every |
Batch-Fit Models#
Most scikit-learn models are trained on the full dataset at once. These are used with
FitPhase in an Experiment rather than TrainPhase.
from sklearn.ensemble import RandomForestRegressor
from modularml.core.models import ScikitModelWrapper
sklearn_model = ScikitModelWrapper(
model=RandomForestRegressor(n_estimators=50, random_state=42),
)
print(f"Backend: {sklearn_model.backend}")
print(f"Supports partial_fit: {sklearn_model.supports_partial_fit}")
print(f"Training mode: {sklearn_model.resolved_training_mode}")
Incremental Models#
Models that support partial_fit() can be used with TrainPhase for mini-batch
training, similar to neural network workflows.
from sklearn.linear_model import SGDRegressor
incremental_model = ScikitModelWrapper(
model=SGDRegressor(random_state=42),
)
print(f"Supports partial_fit: {incremental_model.supports_partial_fit}")
print(f"Training mode: {incremental_model.resolved_training_mode}")
Auto-Wrapping#
Like PyTorch models, raw scikit-learn estimators passed to ModelNode are automatically
wrapped via wrap_model().
auto_sklearn = wrap_model(RandomForestRegressor(n_estimators=10))
print(f"Type: {type(auto_sklearn).__name__}")
print(f"Backend: {auto_sklearn.backend}")
Creating a ModelNode#
A ModelNode combines a model with an upstream data source and an optional optimizer.
ModelNode(
label: str,
model: BaseModel | Any,
upstream_ref: ExperimentNode | ExperimentNodeReference,
optimizer: Optimizer | None = None,
)
Parameter |
Description |
|---|---|
|
Unique name for this node within the graph. |
|
A |
|
The data source: a |
|
Optional |
With a Built-In Model#
node_mlp = ModelNode(
label="MyMLP",
model=SequentialMLP(output_shape=(1, 1), n_layers=2, hidden_dim=32),
upstream_ref=fs_ref,
)
print(node_mlp)
print(f" is_built: {node_mlp.is_built}")
print(f" backend: {node_mlp.backend}")
With a Custom torch.nn.Module (Auto-Wrapped)#
# Pass a raw torch.nn.Module - it is automatically wrapped by wrap_model()
node_custom = ModelNode(
label="MyCustomEncoder",
model=MyEncoder(in_features=10, out_features=4),
upstream_ref=fs_ref,
)
print(node_custom)
print(f" model type: {type(node_custom.model).__name__}")
print(f" backend: {node_custom.backend}")
With a Scikit-Learn Model#
node_rf = ModelNode(
label="RandomForest",
model=RandomForestRegressor(n_estimators=50, random_state=42),
upstream_ref=fs_ref,
)
print(node_rf)
print(f" model type: {type(node_rf.model).__name__}")
print(f" backend: {node_rf.backend}")
The Optimizer#
The Optimizer class wraps backend-specific optimizers with a consistent API.
Optimizer(
opt: str | type | None = None,
*,
opt_kwargs: dict[str, Any] | None = None,
factory: Callable | None = None,
backend: Backend | None = None,
)
There are three ways to specify the optimizer:
# 1. By name string (most common)
opt_by_name = Optimizer("adam", opt_kwargs={"lr": 1e-3}, backend="torch")
# 2. By optimizer class
opt_by_class = Optimizer(
torch.optim.AdamW,
opt_kwargs={"lr": 1e-3, "weight_decay": 1e-4},
)
# 3. By factory callable
opt_by_factory = Optimizer(
factory=lambda params: torch.optim.SGD(params, lr=0.01, momentum=0.9),
backend="torch",
)
print(f"By name: {opt_by_name.name}")
print(f"By class: {opt_by_class.cls}")
print(f"By factory: {opt_by_factory}")
Attaching an Optimizer to a ModelNode#
An optimizer on a ModelNode enables standalone train_step() / eval_step() calls.
If creating a ModelGraph with models all from the same backend (e.g., all PyTorch models), it’s easier to just use a graph-wise optimizer (set during ModelGraph init).
node_with_opt = ModelNode(
label="TrainableMLP",
model=SequentialMLP(output_shape=(1, 1), n_layers=2, hidden_dim=32),
upstream_ref=fs_ref,
optimizer=Optimizer("adam", opt_kwargs={"lr": 1e-3}, backend="torch"),
)
print(f"Has optimizer: {node_with_opt._optimizer is not None}")
Building and Running a ModelNode#
Normally ModelGraph.build() handles building all nodes. But for debugging or
standalone use, you can build and forward through a ModelNode directly.
Building Manually#
# build_model() takes explicit shapes
node_mlp.build_model(input_shape=(1, 10), output_shape=(1, 1))
print(f"is_built: {node_mlp.is_built}")
print(f"input_shape: {node_mlp.input_shape}")
print(f"output_shape: {node_mlp.output_shape}")
Forward Pass with SampleData#
The forward_single() method (also available as __call__) accepts SampleData,
RoleData, or Batch. It passes features through the model, preserving targets and tags.
from modularml.core.data.sample_data import SampleData
from modularml.utils.data.data_format import DataFormat
# Create SampleData from the FeatureSet reference
fsv = fs_ref.resolve()
sample_data = SampleData(
features=fsv.get_features(fmt=DataFormat.TORCH),
targets=fsv.get_targets(fmt=DataFormat.TORCH),
)
print(f"Input features: {sample_data.features.shape}")
# Forward pass
with torch.no_grad():
output = node_mlp(sample_data)
print(f"Output features: {output.features.shape}")
print(f"Targets passed through: {output.targets.shape}")
Auto-Build on First Forward Pass#
If a ModelNode has a FeatureSetReference as its upstream, calling forward_single()
on an unbuilt node will attempt to auto-build by inferring shapes from the upstream
FeatureSet.
Note that output_shape will be determined in the following sequence:
If provided, that output shape is used
If the node has no downstream connections, the target shape of the referenced FeatureSet will be used
Otherwise, the hidden layer shape (if using a built model) will be used.
# This node is not built yet
auto_build_node = ModelNode(
label="AutoBuild",
model=SequentialMLP(output_shape=(1, 1), n_layers=1, hidden_dim=16),
upstream_ref=fs_ref,
)
print(f"Before forward: is_built={auto_build_node.is_built}")
# First forward pass triggers auto-build
with torch.no_grad():
output = auto_build_node(sample_data)
print(f"After forward: is_built={auto_build_node.is_built}")
print(f" input_shape: {auto_build_node.input_shape}")
print(f" output_shape: {auto_build_node.output_shape}")
We could’ve omitted output_shape, which results in the same (1,1) shape (because the FeatureSet 'soh' data has shape (1,1)).
It is generally best practice to explicitly define the output shape of any models you create.
Chaining Nodes#
A ModelNode can take another ModelNode (or any ComputeNode) as its upstream,
enabling multi-stage pipelines.
# Encoder -> Regressor chain
encoder = ModelNode(
label="Encoder",
model=SequentialMLP(output_shape=(1, 8), n_layers=2, hidden_dim=32),
upstream_ref=fs_ref,
)
regressor = ModelNode(
label="Regressor",
model=SequentialMLP(output_shape=(1, 1), n_layers=1, hidden_dim=16),
upstream_ref=encoder, # Receives output from Encoder
)
print(f"Encoder upstream: {encoder.upstream_ref.resolve()}")
print(f"Regressor upstream: {regressor.upstream_ref.resolve()}")
Freezing and Unfreezing#
Freezing a node prevents its parameters from being updated during training. This is useful for transfer learning or multi-stage training events.
The below requires_grad property is PyTorch-specific, but similar gradient blocking is enforced for TensorFlow models.
print(f"Frozen: {node_mlp.is_frozen}")
node_mlp.freeze()
print(f"After freeze: {node_mlp.is_frozen}")
# Verify PyTorch parameters are frozen
param = next(node_mlp.model.parameters())
print(f" - requires_grad: {param.requires_grad}")
node_mlp.unfreeze()
print(f"After unfreeze: {node_mlp.is_frozen}")
print(f" - requires_grad: {param.requires_grad}")
Serialization#
ModelNode supports full config and state serialization via get_config() / from_config()
and get_state() / set_state(). The underlying BaseModel handles weight serialization.
# Configuration (structure, no weights)
config = node_mlp.get_config()
print("Config keys:", list(config.keys()))
# State (includes learned weights)
state = node_mlp.get_state()
print("State keys:", list(state.keys()))
print("Model weight keys:", list(state["model"]["weights"].keys())[:3], "...")
Models can also be saved to and loaded from disk independently of the ModelNode.
Note that save and load methods are not provided on ModelNodes, only on the BaseModel itself.
This is intentional.
ModelNodes are not useful outside of its parent Experiment (their upstream and downstream connections have no meaning on their own).
However, the underlying model is useful to share independently.
from pathlib import Path
from tempfile import TemporaryDirectory
from modularml import BaseModel
SAVE_DIR = TemporaryDirectory()
# Save and reload a built-in model
save_path = node_mlp.model.save(Path(SAVE_DIR.name) / "my_mlp", overwrite=True)
reloaded = BaseModel.load(save_path)
print(f"Models equal: {reloaded == node_mlp.model}")
For custom (non-built-in) models, the model’s source code is packaged alongside the weights. This requires that the custom model be defined in a standalone python file.
# Save a wrapped custom model
node_custom.build_model(input_shape=(10,), output_shape=(4,))
try:
save_path_custom = node_custom.model.save(
Path(SAVE_DIR.name) / "my_custom",
overwrite=True,
)
except RuntimeError as e:
print(e)
from utils.my_model import MyEncoder
# After moving the MyEncoder class to an external python file, we can save
custom_node = ModelNode(
label="imported_model",
model=MyEncoder(in_features=10, out_features=4),
upstream_ref=fs_ref,
)
save_path_custom = custom_node.model.save(
Path(SAVE_DIR.name) / "my_custom",
overwrite=True,
)
Packaging source code is the only way to ensure full reproducibility of custom code. However, you should always inspect unknown code below executing it.
If you try to load a saved file that contains packaged code, an error will be thrown unless you intentionally set allow_packaged_code=True.
The following procedure is recommended when loading unknown serialized mml files:
Call
load(..., allow_packaged_code=False)(it will always defaul to False)If an error occurs, indicating packaged code, use the following utility to inspect the source code before importing as an executable:
modularml.utils.inspect_packaged_codeAfter verifying the nature of the source code, you can retry
loadwithallow_packaged_code=True
# Reload with packaged code
try:
reloaded_custom = BaseModel.load(save_path_custom, allow_packaged_code=False)
except RuntimeError as e:
print(e)
from modularml.utils import inspect_packaged_code
# Inspect the file before reloadinge
# The method returns a dict with keys for each class that would need to be loaded
res = inspect_packaged_code(save_path_custom)
for k, code in res.items():
print(k)
print(code)
# Now that we've verified the code is safe to run, we can try loading again
reloaded_custom = BaseModel.load(save_path_custom, allow_packaged_code=True)
print(f"Models equal: {reloaded_custom == custom_node.model}")
Summary#
Model Classes#
Class |
Module |
Backend |
Description |
|---|---|---|---|
|
|
Abstract |
Base interface for all models. |
|
|
PyTorch |
Base for built-in PyTorch models. |
|
|
PyTorch |
Wraps any |
|
|
TensorFlow |
Wraps any |
|
|
scikit-learn |
Wraps any |
|
|
PyTorch |
Built-in multi-layer perceptron. |
|
|
PyTorch |
Built-in 1D convolutional network. |
ModelNode Properties and Methods#
Property / Method |
Description |
|---|---|
|
The underlying |
|
Backend enum ( |
|
Whether the model has been built with input/output shapes. |
|
Input shape tuple (no batch dim), or |
|
Output shape tuple (no batch dim), or |
|
The single upstream reference (read-only property). |
|
Whether training is disabled for this node. |
|
Build the model and optimizer manually. |
|
Forward pass on |
|
Toggle parameter trainability. |
|
Config serialization (structure only). |
|
State serialization (includes weights). |
Next Steps#
ModelGraph: Compose multiple
ModelNodes (andMergeNodes) into a computational graph that handles build order, shape inference, and forward pass routing.Experiment: Use
Experimentto combine aModelGraphwith training phases, loss functions, and evaluation — the primary user-facing entry point.