A build-it-yourself guide. ModuLearn is task-agnostic — the
compiler and canvas never knew what "classification" was. So adding regression means
no core changes at all: a new model, a new dataset kind, and one branch in
on_train. The result is a self-contained example, examples/regression.py.
Classifier vs. regressor is a model choice, and the data it
needs is different. Encode that in the kind: classification datasets stay
tabular; regression data becomes tabular-reg. Each model declares
the kind it requires. Now the compiler's existing type check does something new
for free — it blocks a regressor wired to classification data, and vice versa, right
on the canvas.
| Piece | What it is | Where |
|---|---|---|
| Regression data | continuous target, kind="tabular-reg" | a loader + a dataset node |
| MLP Regressor | requires_kind="tabular-reg" | a model node |
| Metric | RMSE per epoch (lower=better), R² as the score | on_train branch |
| Dispatch | if compiled.model == "mlp_reg" | on_train |
"model 'mlp_reg' needs a 'tabular-reg' dataset but 'iris' is 'tabular'".Core ModuLearn has no ML dependency; this example does. Install it however you manage deps:
pip install scikit-learn # or: uv add scikit-learn
Optionally record it as an extra in pyproject.toml so the example is
reproducible: [project.optional-dependencies] → examples = ["scikit-learn"].
Make examples/regression.py. Data is generated in-process, so there's
nothing to download and no file to commit. The target is standardized — that's not
cosmetic (see step 5's trap).
"""ModuLearn regression example — an MLP Regressor alongside a classifier.
pip install scikit-learn
modulearn run examples/regression.py
"""
import numpy as np
from sklearn.datasets import load_iris, make_regression
from sklearn.model_selection import train_test_split
from sklearn.neural_network import MLPClassifier, MLPRegressor
from sklearn.metrics import log_loss, mean_squared_error, r2_score
from modulearn import Registry, Param, create_app
def load_iris_():
return load_iris(return_X_y=True)
def load_synth_reg():
X, y = make_regression(n_samples=4000, n_features=8, n_informative=6,
noise=12.0, random_state=42)
y = (y - y.mean()) / y.std() # standardize target -> RMSE ~O(1), stable
return X, y
LOADERS = {"iris": load_iris_, "synth_reg": load_synth_reg}
Two datasets and two models, each model bound to a kind. This is the
whole type-safety story — no extra validation code needed.
reg = Registry()
reg.add_dataset("iris", title="Iris (classification)", kind="tabular",
features=["sepal_len", "sepal_wid", "petal_len", "petal_wid"],
targets=["species"])
reg.add_dataset("synth_reg", title="Synthetic (regression)", kind="tabular-reg",
features=[f"f{i}" for i in range(8)], targets=["target"])
reg.add_model("mlp", title="MLP Classifier", requires_kind="tabular",
params=[Param("hidden", "hidden layers", "int_list", [32, 16])])
reg.add_model("mlp_reg", title="MLP Regressor", requires_kind="tabular-reg",
params=[Param("hidden", "hidden layers", "int_list", [32, 16])])
reg.add_hyperparameter("lr", label="learning rate", kind="float",
default=1e-3, min=1e-6, max=1.0)
reg.add_hyperparameter("epochs", label="epochs", kind="int",
default=60, min=1, max=1000)
Load whichever dataset was wired, split it, read the hyperparameters, then branch on
compiled.model. The regression branch is the new part.
def on_train(compiled, reporter):
X, y = LOADERS[compiled.dataset]()
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.25, random_state=0)
hp = compiled.hyperparameters
epochs = int(hp.get("epochs", 60))
hidden = tuple(compiled.model_params.get("hidden", [32, 16]))
lr = float(hp.get("lr", 1e-3))
reporter.state(epochs=epochs)
partial_fit gives one epoch per call, so the live curve animates.
RMSE is a loss — lower is better — so it charts exactly like the classifier's
cross-entropy. R² is the held-out score.
if compiled.model == "mlp_reg":
est = MLPRegressor(hidden_layer_sizes=hidden, learning_rate_init=lr,
max_iter=1, warm_start=True)
for e in range(epochs):
est.partial_fit(Xtr, ytr)
train = mean_squared_error(ytr, est.predict(Xtr)) ** 0.5
val = mean_squared_error(yte, est.predict(Xte)) ** 0.5
reporter.metric(epoch=e, train=round(train, 4), val=round(val, 4))
reporter.state(epoch=e, best_val=round(val, 4))
reporter.state(phase="done",
test_score=round(r2_score(yte, est.predict(Xte)), 4))
mean_squared_error(...) ** 0.5, not the squared=False argument,
which is deprecated in recent scikit-learn. This form works on every version.The else is your existing classifier flow. Then the module-level
app so modulearn run finds it.
else:
classes = list(np.unique(y))
est = MLPClassifier(hidden_layer_sizes=hidden, learning_rate_init=lr,
max_iter=1, warm_start=True)
for e in range(epochs):
est.partial_fit(Xtr, ytr, classes=classes)
train = log_loss(ytr, est.predict_proba(Xtr), labels=classes)
val = log_loss(yte, est.predict_proba(Xte), labels=classes)
reporter.metric(epoch=e, train=round(train, 4), val=round(val, 4))
reporter.state(epoch=e, best_val=round(val, 4))
reporter.state(phase="done", test_score=round(est.score(Xte, yte), 4))
app = create_app(reg, on_train, title="ModuLearn", subtitle="regression example")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8000)
modulearn run examples/regression.py
# open http://localhost:8000
Drop Synthetic (regression) → MLP Regressor → Train, wire them, add an
epochs node, and hit ▶. The curve should fall from ~1.1 toward ~0.15, with
R² near 0.98 in the panel.
This is the payoff — confirm it, don't assume it. On the canvas, try wiring
Iris → MLP Regressor: the ports connect (both are the dataset family)
but the panel goes red with:
model 'mlp_reg' needs a 'tabular-reg' dataset but 'iris' is 'tabular'
Symmetrically, Synthetic (regression) → MLP Classifier is refused too. The Train button stays disabled until the pairing is valid.
git add examples/regression.py
git commit -m "Add regression example (MLP Regressor, RMSE/R2)"
git push
gh run list --branch main --limit 1 # expect: success
The example isn't imported by the test suite, so CI stays green regardless of whether scikit-learn is installed in the runner.
In docs/roadmap.html, add done:true, to the
{area:"features", … title:"Regression support"} item. Features ticks to 1/4.
Swapping the synthetic set for real data (the house-prices CSV you
have, say) is just a new loader — the registry and on_train don't change:
import pandas as pd
def load_house():
df = pd.read_csv("data/house_prices.csv")[["GrLivArea", "OverallQual", "YearBuilt", "SalePrice"]].dropna()
y = df.pop("SalePrice").to_numpy(float)
y = (y - y.mean()) / y.std() # same standardization the synthetic loader uses
return df.to_numpy(float), y
LOADERS["house"] = load_house
# ...then add_dataset("house", kind="tabular-reg", features=[...], targets=["SalePrice"])
load_house, never in
ModuLearn. Keep the loader returning clean numeric (X, y) and everything upstream
just works.