Guides#

mononet ships monotonic layers, not composed models — stack them with your framework’s native Sequential (or equivalent); there is no composed MonoMLP. Every backend exposes the same three layers: the dense monotone layer (a monotonic analogue of the framework’s dense layer, non-decreasing in all inputs), MonoResidual (a dual-gated monotone residual block, warm-started near identity), and MonoInput (a sign-flip layer encoding per-feature monotonicity directions).

Example#

A mixed-feature network: monotone in 3 features (2 non-decreasing, 1 non-increasing) via MonoInput, and unconstrained in 2 non-monotone features, which are embedded through a plain MLP before being concatenated with the monotone path. The embedding absorbs the non-monotonicity, so the composite RiskNet is monotone in x_mono and free in x_free. The dense layer and MonoResidual default to mode="mixed". Pick your backend:

mononet.torch provides monotonic layers as torch.nn.Module subclasses; they drop into any training loop (plain PyTorch, PyTorch Lightning, …) and compose with torch.nn.Sequential.

pip install "mononet[torch]"

Layers: mononet.torch.layers.MonoLinear (monotonic torch.nn.Linear), mononet.torch.layers.MonoResidual, mononet.torch.layers.MonoInput.

# SPDX-License-Identifier: Apache-2.0
"""Mixed-feature monotone network (PyTorch).

Monotone in 3 features (2 non-decreasing, 1 non-increasing) via ``MonoInput``,
and unconstrained in 2 non-monotone features, which are embedded through a
plain MLP. The embedding absorbs the non-monotonicity, so the composite map is
monotone in ``x_mono`` and free in ``x_free``. Mixed mode is the default.
"""

from __future__ import annotations

import numpy as np
import torch
from torch import nn

from mononet import MonotonicityMask
from mononet.torch import MonoInput, MonoLinear, MonoResidual


class RiskNet(nn.Module):
    """Monotone in ``x_mono`` (directions +1, +1, -1); free in ``x_free``."""

    def __init__(self) -> None:
        super().__init__()
        self.embed = nn.Sequential(
            nn.Linear(2, 16),
            nn.ReLU(),
            nn.Linear(16, 8),
            nn.ReLU(),
        )
        self.mono_in = MonoInput(MonotonicityMask(np.array([1, 1, -1], dtype=np.int8)))
        self.net = nn.Sequential(
            MonoLinear(11, 64, activation="elu"),
            MonoResidual(64, 64, activation="elu"),
            MonoResidual(64, 64, activation="elu"),
            MonoLinear(64, 1),
        )

    def forward(self, x_mono: torch.Tensor, x_free: torch.Tensor) -> torch.Tensor:
        """Combine the sign-flipped monotone features with the free embedding."""
        z = torch.cat([self.mono_in(x_mono), self.embed(x_free)], dim=-1)
        return self.net(z)

For per-feature monotonicity directions, pass a MonotonicityMask (a 1-D array of {-1, +1}) to MonoInput.

mononet.jax uses Flax NNX — layers are flax.nnx.Module subclasses, fully compatible with jax.jit() and jax.grad(), and compose with flax.nnx.Sequential.

pip install "mononet[jax]"

Layers: mononet.jax.layers.MonoLinear (monotonic flax.nnx.Linear), mononet.jax.layers.MonoResidual, mononet.jax.layers.MonoInput.

# SPDX-License-Identifier: Apache-2.0
"""Mixed-feature monotone network (JAX / Flax NNX). See risk_net_torch.py."""

from __future__ import annotations

import jax.numpy as jnp
import numpy as np
from flax import nnx

from mononet import MonotonicityMask
from mononet.jax import MonoInput, MonoLinear, MonoResidual


class RiskNet(nnx.Module):
    """Monotone in ``x_mono`` (directions +1, +1, -1); free in ``x_free``."""

    def __init__(self, *, rngs: nnx.Rngs) -> None:
        self.embed1 = nnx.Linear(2, 16, rngs=rngs)
        self.embed2 = nnx.Linear(16, 8, rngs=rngs)
        self.mono_in = MonoInput(MonotonicityMask(np.array([1, 1, -1], dtype=np.int8)))
        self.l1 = MonoLinear(11, 64, activation="elu", rngs=rngs)
        self.r1 = MonoResidual(64, 64, activation="elu", rngs=rngs)
        self.r2 = MonoResidual(64, 64, activation="elu", rngs=rngs)
        self.head = MonoLinear(64, 1, rngs=rngs)

    def __call__(self, x_mono: jnp.ndarray, x_free: jnp.ndarray) -> jnp.ndarray:
        """Combine the sign-flipped monotone features with the free embedding."""
        h = nnx.relu(self.embed1(x_free))
        h = nnx.relu(self.embed2(h))
        z = jnp.concatenate([self.mono_in(x_mono), h], axis=-1)
        return self.head(self.r2(self.r1(self.l1(z))))

The dense layers take an explicit rngs (flax.nnx.Rngs) for weight initialization. For per-feature monotonicity directions, pass a MonotonicityMask (a 1-D array of {-1, +1}) to MonoInput.

mononet.keras uses keras.ops, so the same code runs whether Keras is configured to use JAX, TensorFlow, or PyTorch under the hood (the GPU devcontainer ships with KERAS_BACKEND=jax).

pip install "mononet[keras]"

Layers: mononet.keras.layers.MonoDense (monotonic keras.layers.Dense), mononet.keras.layers.MonoResidual, mononet.keras.layers.MonoInput.

# SPDX-License-Identifier: Apache-2.0
"""Mixed-feature monotone network (Keras 3). See risk_net_torch.py."""

from __future__ import annotations

from typing import Any

import keras
import numpy as np

from mononet import MonotonicityMask
from mononet.keras import MonoDense, MonoInput, MonoResidual


class RiskNet(keras.Model):  # type: ignore[misc]
    """Monotone in ``x_mono`` (directions +1, +1, -1); free in ``x_free``."""

    def __init__(self) -> None:
        super().__init__()
        self.embed1 = keras.layers.Dense(16, activation="relu")
        self.embed2 = keras.layers.Dense(8, activation="relu")
        self.mono_in = MonoInput(MonotonicityMask(np.array([1, 1, -1], dtype=np.int8)))
        self.l1 = MonoDense(64, activation="elu")
        self.r1 = MonoResidual(64, activation="elu")
        self.r2 = MonoResidual(64, activation="elu")
        self.head = MonoDense(1)

    def call(self, x_mono: Any, x_free: Any) -> Any:
        """Combine the sign-flipped monotone features with the free embedding."""
        h = self.embed2(self.embed1(x_free))
        z = keras.ops.concatenate([self.mono_in(x_mono), h], axis=-1)
        return self.head(self.r2(self.r1(self.l1(z))))

MonoDense infers the input width at build time (Keras style) — no in_features. MonoDense and MonoInput implement get_config/from_config, so models serialize with the standard Keras saving APIs. For per-feature monotonicity directions, pass a MonotonicityMask (a 1-D array of {-1, +1}) to MonoInput.

See also#