Roadmap · Features · P1 · L

Regression support

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.

Done when: an MLP Regressor trains on continuous targets alongside the existing classifier, reporting RMSE per epoch and as the score — and the type system refuses to wire a regressor to classification data.
0 / 0 steps

The idea: the typed kind does the work

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.

PieceWhat it isWhere
Regression datacontinuous target, kind="tabular-reg"a loader + a dataset node
MLP Regressorrequires_kind="tabular-reg"a model node
MetricRMSE per epoch (lower=better), R² as the scoreon_train branch
Dispatchif compiled.model == "mlp_reg"on_train
Verified. The code below was run end-to-end against the live server: a regression run trained from val RMSE 1.16 → 0.15 with R² ≈ 0.98, and the compile endpoint rejected a regressor+Iris graph with "model 'mlp_reg' needs a 'tabular-reg' dataset but 'iris' is 'tabular'".

Steps

  1. Add scikit-learn

    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"].

  2. Create the file: docstring, imports, loaders

    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}
  3. Declare the typed registry

    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)
  4. Write on_train — shared setup + dispatch

    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)
  5. The regression branch (RMSE + R²)

    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))
    Two traps, both handled above. (1) The target is standardized in the loader — MLPRegressor on raw large-magnitude targets often diverges. (2) RMSE is mean_squared_error(...) ** 0.5, not the squared=False argument, which is deprecated in recent scikit-learn. This form works on every version.
  6. Keep the classifier branch + finish the file

    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)
  7. Run it and watch it learn
    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.

  8. Prove the type system blocks a bad mix

    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.

  9. Commit, push, confirm CI
    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.

  10. Mark it shipped on the roadmap

    In docs/roadmap.html, add done:true, to the {area:"features", … title:"Regression support"} item. Features ticks to 1/4.

Going further: your own data

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"])
Real CSVs need cleaning in the loader — drop or fill NaNs, encode text columns, standardize the target. That work belongs in load_house, never in ModuLearn. Keep the loader returning clean numeric (X, y) and everything upstream just works.