Ciaren

Build an Advanced Plugin (scikit-learn)

Build an Advanced Plugin: a scikit-learn Classifier

The first-plugin tutorial builds the smallest node that works. This one builds a realistic node: it trains a scikit-learn MLPClassifier with real hyperparameters, validates its config thoroughly, runs end-to-end, and exports runnable scikit-learn code.

The finished plugin lives in the repo at examples/plugins/mlp-classifier-plugin/ and ships bundled in the Explore catalog, so a fresh install can try it immediately. Open it alongside this page for the complete source.

What makes it "advanced"

Six things the Hello node skips: multiple hyperparameters, input validation that fails fast with clear messages, an optional third-party dependency (scikit-learn) imported safely, faithful code export of a multi-line body, model persistence through the host's MLflow-backed ModelStore, and a contributed model type that appears inside the core Train Classifier node.

The one design decision that shapes everything

A plugin node's runtime is pandas in → pandas out. Ciaren converts to and from the active engine (pandas/polars) around your code. That means a plugin node returns DataFrames, not model objects — the contract carries dataframes.

So a train node doesn't hand back a fitted estimator. It persists the estimator through the host's ModelStore (context.models — it becomes an MLflow artifact) and emits a one-row model reference frame on a typed model output handle, plus a metrics frame on a second handle. The reference is what the core Predict and Feature Importance nodes consume; the raw model never travels through the graph. See ML Model Plugins for the full contract.

1. Scaffold the package

mlp-classifier-plugin/
├── ciaren_mlp/
│   ├── __init__.py
│   └── plugin.py
├── ciaren-plugin.json      # generated from the code (step 5)
└── pyproject.toml
# pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "ciaren-mlp-classifier-plugin"
version = "0.2.0"
requires-python = ">=3.12"
dependencies = ["scikit-learn>=1.3", "pandas>=2.0"]

[project.entry-points."ciaren.plugins"]
mlp_classifier = "ciaren_mlp.plugin:MlpClassifierPlugin"

2. Validate the hyperparameters up front

Validation is what separates a toy from a usable node. Do it before scikit-learn runs, so the editor and the exported code agree on what "valid" means and the user gets a clear message instead of a stack trace from deep inside sklearn.

Centralize it in one resolve_config that merges defaults, checks every field, and returns typed values. validate_config just calls it and discards the result.

ACTIVATIONS = ("identity", "logistic", "tanh", "relu")
SOLVERS = ("lbfgs", "sgd", "adam")

DEFAULT_CONFIG = {
    "target_column": "", "feature_columns": [],
    "hidden_layer_sizes": "100", "activation": "relu", "solver": "adam",
    "alpha": 0.0001, "learning_rate_init": 0.001, "max_iter": 200,
    "test_size": 0.2, "random_state": 42, "stratify": True,
}

def resolve_config(config: dict) -> dict:
    cfg = {**DEFAULT_CONFIG, **(config or {})}
    target = str(cfg["target_column"]).strip()
    if not target:
        raise ValueError("mlpClassifierTrain: 'target_column' is required")
    if cfg["activation"] not in ACTIVATIONS:
        raise ValueError(f"'activation' must be one of {ACTIVATIONS}")
    if cfg["solver"] not in SOLVERS:
        raise ValueError(f"'solver' must be one of {SOLVERS}")
    test_size = float(cfg["test_size"])
    if not 0.0 < test_size < 1.0:
        raise ValueError("'test_size' must be between 0 and 1 (exclusive)")
    # …check alpha >= 0, learning_rate_init > 0, max_iter > 0, parse hidden_layer_sizes…
    return { "target_column": target, "test_size": test_size, ... }

hidden_layer_sizes is worth special handling — accept an int, a list, or a "64,32" string, and coerce to the tuple of positive ints scikit-learn expects:

def parse_hidden_layers(value) -> tuple[int, ...]:
    if isinstance(value, int) and not isinstance(value, bool):
        items = [value]
    elif isinstance(value, (list, tuple)):
        items = list(value)
    elif isinstance(value, str):
        items = [p.strip() for p in value.split(",") if p.strip()]
    else:
        raise ValueError("'hidden_layer_sizes' has an unsupported type")
    sizes = [int(n) for n in items]                       # raises on "abc"
    if not sizes or any(n <= 0 for n in sizes):
        raise ValueError("'hidden_layer_sizes' must be positive integers, e.g. '64,32'")
    return tuple(sizes)

See the full validation (every field, with _as_int/_as_float helpers) in the example's plugin.py.

3. Write the runtime

Two rules for depending on scikit-learn:

  1. Import it lazily, inside execute/to_python_code — never at module top level. Then the plugin still registers and shows in the catalog on machines without scikit-learn; running the node raises a clear "install it" message.
  2. Validate the data, not just the config — target present, features present and numeric (an MLP can't train on strings).

The runtime overrides execute_with_context (not plain execute) because it needs two host services: the preview flag and the ModelStore.

from app.plugin_api import ModelRef, NodeContext, NodeRuntime

class MlpClassifierTrainRuntime(NodeRuntime):
    def validate_config(self, config):
        resolve_config(config)            # raise ValueError on anything unsupported

    def execute_with_context(self, inputs, config, context: NodeContext):
        import pandas as pd
        p = resolve_config(config)

        # Editor previews run on sampled data — don't fit or persist anything.
        if context.in_preview:
            placeholder = ModelRef(task_type="classification", model_type="mlp_classifier",
                                   target_column=p["target_column"])
            return {"model": placeholder.to_frame(), "metrics": pd.DataFrame()}

        try:
            from sklearn.metrics import accuracy_score
            from sklearn.model_selection import train_test_split
            from sklearn.neural_network import MLPClassifier
        except ImportError as exc:
            raise ValueError("mlpClassifierTrain needs scikit-learn — pip install scikit-learn") from exc

        df, target = inputs["in"], p["target_column"]
        if target not in df.columns:
            raise ValueError(f"target column {target!r} is not in the input")
        features = p["feature_columns"] or [c for c in df.columns if c != target]
        non_numeric = [c for c in features if not pd.api.types.is_numeric_dtype(df[c])]
        if non_numeric:
            raise ValueError(f"feature columns must be numeric; non-numeric: {non_numeric}")

        X, y = df[features], df[target]
        # Stratify only when valid, else scikit-learn would raise.
        stratify = y if p["stratify"] and y.nunique() > 1 and y.value_counts().min() >= 2 else None
        X_train, X_test, y_train, y_test = train_test_split(
            X, y, test_size=p["test_size"], random_state=p["random_state"], stratify=stratify)
        clf = MLPClassifier(
            hidden_layer_sizes=p["hidden_layer_sizes"], activation=p["activation"],
            solver=p["solver"], alpha=p["alpha"], learning_rate_init=p["learning_rate_init"],
            max_iter=p["max_iter"], random_state=p["random_state"])
        clf.fit(X_train, y_train)

        metrics = {
            "n_samples": len(df), "n_features": len(features), "n_classes": len(clf.classes_),
            "train_accuracy": float(accuracy_score(y_train, clf.predict(X_train))),
            "test_accuracy": float(accuracy_score(y_test, clf.predict(X_test))),
            "n_iterations": int(clf.n_iter_), "final_loss": float(clf.loss_),
        }

        # Persist through the host: the estimator becomes an MLflow artifact and
        # only the typed reference travels through the graph.
        if context.models is None:
            raise ValueError("this server has no ML/MLflow support enabled")
        # params + seed land in the reference's model_config_json — the model-wire
        # contract core Cross-Validate rebuilds the estimator from.
        ref = context.models.log_sklearn_model(
            clf, model_type="mlp_classifier", task_type="classification",
            target_column=target, feature_columns=tuple(features),
            params={"hidden_layer_sizes": ",".join(map(str, p["hidden_layer_sizes"])),
                    "activation": p["activation"], "solver": p["solver"],
                    "alpha": p["alpha"], "max_iter": p["max_iter"]},
            metrics={"train_accuracy": metrics["train_accuracy"],
                     "test_accuracy": metrics["test_accuracy"]},
            input_example=X_train.head(5), seed=p["random_state"])
        return {"model": ref.to_frame(), "metrics": pd.DataFrame([metrics])}

Register the node

The spec declares two outputs — a typed model port (only connects to model inputs; graph validation enforces it) and a regular metrics frame — marks the node a flow terminal (a flow may end at it, since it persists a model), and ships a config_schema so the sidebar renders a real form:

class _MlpNodeProvider(NodeProvider):
    def nodes(self):
        return [NodeSpec(
            id="sklearn.mlpClassifierTrain",
            label="MLP Classifier (train)",
            category="ml",
            provider="community.mlp-classifier",
            inputs=(PortSpec(id="in"),),
            outputs=(PortSpec(id="model", type="model"), PortSpec(id="metrics")),
            default_config=dict(DEFAULT_CONFIG),
            capabilities=("node.sklearn.mlp",),
            is_model_sink=True,
            is_flow_terminal=True,
            config_schema={"fields": [
                {"key": "target_column", "label": "Target column", "type": "column", "required": True},
                {"key": "hidden_layer_sizes", "label": "Hidden layers", "type": "string", "default": "100"},
                {"key": "activation", "label": "Activation", "type": "select",
                 "options": ["identity", "logistic", "tanh", "relu"], "default": "relu"},
                # …see the example source for the full field list
            ]},
        )]
    def node_implementations(self):
        return {"sklearn.mlpClassifierTrain": MlpClassifierTrainRuntime()}

category="ml" slots the node into the ML section of the palette. (Only the built-in ML nodes are hidden when CIAREN_ML_ENABLED=false; a plugin node always shows once approved.)

Bonus: put the algorithm inside the core Train Classifier too

The same plugin also registers a ModelProvider, so mlp_classifier appears in the standard Train Classifier model picker and trains through the core pipeline (preprocessing, MLflow logging, code export) with zero extra runtime code — the plugin only supplies the estimator builder and a hyperparameter form schema:

class MlpClassifierPlugin(Plugin):
    def metadata(self):
        return PluginMetadata(id="community.mlp-classifier", name="MLP Classifier", version="0.2.0")
    def register(self, registry):
        registry.register_node_provider(_MlpNodeProvider())
        registry.register_model_provider(_MlpModelProvider())   # see ML Model Plugins

The full _MlpModelProvider is a ~30-line class — walk through it in ML Model Plugins.

4. Export runnable code

to_python_code returns a multi-line string of real scikit-learn code, and imports() declares the imports the script needs — Ciaren dedupes and orders them. Derive helper variable names from the output variable so two training nodes in one flow can't collide, and read the input variable only before assigning the output (so Ciaren's variable-reuse optimization stays correct):

def imports(self, config):
    return ["from sklearn.metrics import accuracy_score",
            "from sklearn.model_selection import train_test_split",
            "from sklearn.neural_network import MLPClassifier"]

def to_python_code(self, input_vars, output_vars, config):
    p = resolve_config(config)
    inp = input_vars["in"]
    model_var = output_vars.get("model", "mlp_model")      # one variable per output handle
    metrics_var = output_vars.get("metrics", "mlp_metrics")
    feat = f"{model_var}_features"
    return "\n".join([
        f"{feat} = [c for c in {inp}.columns if c != {p['target_column']!r}]",
        f"X_tr, X_te, y_tr, y_te = train_test_split({inp}[{feat}], {inp}[{p['target_column']!r}], "
        f"test_size={p['test_size']!r}, random_state={p['random_state']!r})",
        f"{model_var} = MLPClassifier(hidden_layer_sizes={p['hidden_layer_sizes']!r}, max_iter={p['max_iter']!r})",
        f"{model_var}.fit(X_tr, y_tr)",
        f"{metrics_var} = pd.DataFrame([{{'test_accuracy': accuracy_score(y_te, {model_var}.predict(X_te))}}])",
    ])

Because the runtime is pandas-based, Ciaren automatically bridges it into the polars export too (wrapping with to_pandas() / from_pandas()), so both exporters work with no extra code.

5. Generate the manifest and run it

Don't hand-write the manifest — generate it from the code so the two never drift:

ciaren-plugin manifest ./mlp-classifier-plugin \
  --entrypoint ciaren_mlp.plugin:MlpClassifierPlugin

Then load it and try it on the canvas:

export CIAREN_PLUGINS_DIR=/path/to/examples/plugins
ciaren serve

A CSV with numeric features and a label column → MLP Classifier (train) (set target_column in its schema-rendered form) → wire the model output into Predict and the metrics output into a File Output. Or add the core Train Classifier node and pick MLP (neural network) in its model dropdown. Export → Python emits the scikit-learn code either way. A quick dataset to try:

python -c "from sklearn.datasets import load_iris; import pandas as pd; \
d=load_iris(as_frame=True); d.frame.rename(columns={'target':'label'}).to_csv('iris.csv', index=False)"

6. Package, sign, and bundle

Package it into a portable, signed .ciarenplugin (see Packaging & Distribution):

ciaren-plugin pack  ./mlp-classifier-plugin ./mlp.ciarenplugin
ciaren-plugin sign  ./mlp.ciarenplugin --key <private-hex> --key-id my-key-2026
ciaren-plugin verify ./mlp.ciarenplugin

To make it appear in your Explore catalog, add it to a marketplace index:

ciaren-plugin index add ./mlp.ciarenplugin --index ./marketplace.json

The example plugin does exactly this in build_mlp_classifier_ciarenplugin.py: it regenerates the manifest, packs, signs with the demo key, and copies both the package and its index entry into the bundled catalog — which is why a fresh Ciaren install lists the MLP Classifier in Explore next to the Hello plugin, ready to install.

What next?