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.