Skip to main content

Process Intelligence Research

ENFORCE - Nonlinear Constrained Learning with Adaptive-depth Neural Projection

ENFORCE graphical abstract

arXiv PyPI GitHub release Coverage

Nonlinear Constrained Learning with Adaptive-depth Neural Projection.

ENFORCE combines a neural network backbone with an AdaNP (Adaptive-depth Neural Projection) module to drive predictions toward feasibility with respect to nonlinear equality and inequality constraints. At each forward pass, AdaNP iteratively applies a linearize-and-project correction - an SQP-inspired Gauss-Newton step - until the constraint residual falls below a prescribed tolerance ε.

For equality constraints that are affine in the output y, a single NP step achieves exact feasibility. For general nonlinear constraints, ε-feasibility is obtained locally: under standard regularity conditions (LICQ, C² smoothness) and when the backbone prediction is sufficiently close to the constraint manifold, AdaNP reduces the residual ‖c(x,ỹ)‖ below ε with a linear convergence rate. The model is trained with standard unconstrained optimization (Adam), not constrained solvers.

Inequality constraints g(x,y) ≤ 0 are handled via a Fischer-Burmeister (FB) reformulation that converts them into equalities in an extended output space [y, λ], so the same AdaNP projection applies without modification.

Reference

If you use ENFORCE in your work, please cite the paper:

@Article{Lastrucci2025_ENFORCENonlinearConstrained,
  author    = {Lastrucci, Giacomo and Schweidtmann, Artur M.},
  journal   = {arXiv preprint arXiv:2502.06774},
  title     = {ENFORCE: Nonlinear Constrained Learning with Adaptive-depth Neural Projection},
  year      = {2025},
  copyright = {arXiv.org perpetual, non-exclusive license},
  doi       = {10.48550/arXiv.2502.06774},
  keywords  = {Machine Learning (cs.LG), FOS: Computer and information sciences},
  publisher = {arXiv},
}

To refer to a specific version of the software, cite the repository: the "Cite this repository" button on GitHub gives the metadata from CITATION.cff, including the version.

Installation

Requires Python ≥ 3.10 and PyTorch ≥ 2.0.

pip

PyTorch is a dependency of ENFORCE. Install the PyTorch build for your hardware first, following the PyTorch installation guide; pip then keeps it. Otherwise pip installs PyTorch from PyPI (the CUDA build on Linux, the CPU build on Windows and macOS).

# 1. PyTorch for your hardware, e.g.
pip install torch --index-url https://download.pytorch.org/whl/cpu      # CPU
pip install torch --index-url https://download.pytorch.org/whl/cu126    # CUDA 12.6

# 2. ENFORCE
pip install enforce-nn

The tutorial notebooks and benchmark scripts also need matplotlib and pandas, included in the examples extra:

pip install "enforce-nn[examples]"

uv

uv add enforce-nn

In your own project, uv resolves PyTorch from PyPI like pip. To use a specific PyTorch build, set the PyTorch index in your project's pyproject.toml (see uv's PyTorch guide), e.g. for CPU:

[tool.uv.sources]
torch = [{ index = "pytorch-cpu" }]

[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true

From source (development)

git clone https://github.com/process-intelligence-research/ENFORCE
cd ENFORCE
uv sync    # package, examples and dev tools

In this repository, uv sync takes PyTorch from the CPU index set in its pyproject.toml; for CUDA, change that index URL (e.g. to https://download.pytorch.org/whl/cu126). This setting applies only when working in the repository, not to the published package. With pip instead: install PyTorch as above, then pip install -e ".[examples]".

Quick start

Supervised - nonlinear equality constraint

Fit x → (y₁, y₂) subject to the nonlinear constraint (0.5 y₁)² + x² + y₂ = 0:

import numpy as np
import torch
from enforce import Scaler, Constraints, ENFORCE, ENFORCEConfig, ENFORCELoss, ENFORCELossConfig
from enforce.models import MLPConfig, ENFORCEConstrainedMLP
from enforce.engines import Trainer, TrainingConfig, Evaluator, EvaluationConfig
from enforce.data import scale_data

np.random.seed(0)  # data; initial weights: MLPConfig.random_seed, shuffling: TrainingConfig.random_seed

# 1. Data
def make_data(n):
    x = np.random.uniform(-1.0, 1.0, (n, 1))
    return x, np.column_stack([2 * np.sin(5 * x[:, 0]), -(np.sin(5 * x[:, 0]) ** 2 + x[:, 0] ** 2)])

x_train, y_train = make_data(500)
x_test,  y_test  = make_data(200)
x_tr_s, y_tr_s, x_te_s, y_te_s, sp = scale_data(x_train, y_train, x_test, y_test)
x_tr_t, y_tr_t, x_te_t, y_te_t = (torch.tensor(a, dtype=torch.float32) for a in (x_tr_s, y_tr_s, x_te_s, y_te_s))
scaler = Scaler((sp["input_mean"], sp["input_std"]), (sp["output_mean"], sp["output_std"]))

# 2. Constraint c(x, y) = 0  (operates on unscaled x and y, returns [BS])
def my_constraint(x, y):
    return (0.5 * y[:, 0]) ** 2 + x[:, 0] ** 2 + y[:, 1]

constraints = Constraints(c=[my_constraint])

# 3. Build: loss, ENFORCE layer, backbone
loss  = ENFORCELoss(scaler, constraints, ENFORCELossConfig(weight_loss_displacement=0.5))
layer = ENFORCE(scaler, constraints, loss, ENFORCEConfig(training_tolerance=1e-4, inference_tolerance=1e-6, max_it=100))
model = ENFORCEConstrainedMLP(MLPConfig(input_neurons=1, hidden_neurons=64, output_neurons=2, hidden_layers=1), layer)

# 4. Train / evaluate
model  = Trainer(model, loss, scaler, constraints, TrainingConfig(epochs=2000)).fit(x_tr_t, y_tr_t)
result = Evaluator(model, loss, scaler, constraints, EvaluationConfig()).evaluate(x_te_t, y_te_t)

preds = result.predictions  # shape [N, 2], already unscaled
print(f"max |c(x, y)|: {result.metrics['residual_max']:.1e}")  # below inference_tolerance = 1e-6

Each item in c (equalities) or g (inequalities) is either a plain callable returning one constraint [BS], or a Constraint block of several constraints evaluated together. Equations with the same structure are fastest as one vectorised block, optionally with an analytical Jacobian jac(x, y) -> [BS, size, NO]:

from enforce import Constraint, Constraints
import torch

A = torch.tensor([[1.0, 2.0], [0.0, 1.0]])  # equations A y = x, [size, NO]
constraints = Constraints(c=[Constraint(lambda x, y: y @ A.T - x, size=A.shape[0], jac=lambda x, y: A.expand(len(y), -1, -1))])

Self-supervised - parametric optimization with inequality

For each x ∈ [2, 4], minimize ‖y‖² subject to y₁² + y₂ = x (equality) and y₁ ≥ 0 (inequality via FB):

import numpy as np
import torch
from enforce import Scaler, Constraints, ENFORCE, ENFORCEConfig, ENFORCELossConfig
from enforce.losses import ENFORCESSLLoss
from enforce.models import MLPConfig, ENFORCEConstrainedMLP
from enforce.engines import Trainer, TrainingConfig, Evaluator, EvaluationConfig
from enforce.data import scale_data

np.random.seed(0)  # data; initial weights: MLPConfig.random_seed, shuffling: TrainingConfig.random_seed

# 1. Constraints: equality c(x, y) = 0 and inequality g(x, y) <= 0
def parabola(x, y):  return y[:, 0] ** 2 + y[:, 1] - x[:, 0]
def g_nonneg(x, y):  return -y[:, 0]   # y1 >= 0  =>  g = -y1 <= 0

constraints = Constraints(c=[parabola], g=[g_nonneg])

# 2. SSL objective - minimize ||y||² (yhat = [y, λ]: drop the FB multiplier column)
class MinNorm(ENFORCESSLLoss):
    def ssl_loss(self, x, yhat):
        return torch.mean(torch.sum(yhat[:, :2] ** 2, dim=1))

# 3. Dummy labels (no targets needed in self-supervised mode), extended with one zero λ column per inequality
N = 2000
x_train = np.random.uniform(2.0, 4.0, (N, 1)).astype(np.float32)
y_dummy = constraints.extend_outputs(np.zeros((N, 2), dtype=np.float32))
x_tr_s, y_tr_s, _, _, sp = scale_data(x_train, y_dummy, x_train, y_dummy)
x_tr_t, y_tr_t = torch.tensor(x_tr_s), torch.tensor(y_tr_s)
scaler = Scaler((sp["input_mean"], sp["input_std"]), (sp["output_mean"], sp["output_std"]))

# 4. Build - the backbone predicts y only (scaler.no - constraints.ng = 2); λ is appended by ENFORCE
loss  = MinNorm(scaler, constraints, ENFORCELossConfig(weight_loss_displacement=0.5))
layer = ENFORCE(scaler, constraints, loss, ENFORCEConfig(training_tolerance=1e-4, inference_tolerance=1e-6, max_it=100))
mlp_cfg = MLPConfig(input_neurons=1, hidden_neurons=64, output_neurons=scaler.no - constraints.ng, hidden_layers=2)
model = ENFORCEConstrainedMLP(mlp_cfg, layer)

# 5. Train / evaluate
model  = Trainer(model, loss, scaler, constraints, TrainingConfig(epochs=2000)).fit(x_tr_t, y_tr_t)
result = Evaluator(model, loss, scaler, constraints, EvaluationConfig()).evaluate(x_tr_t, y_tr_t)
preds  = result.predictions  # [N, 2] - y1, y2 only (multipliers removed)
print(f"max |c(x, y)|: {result.metrics['residual_max']:.1e}, "  # below inference_tolerance = 1e-6
      f"max g(x, y) violation: {result.metrics['ineq_g1_max_violation']:.1e}")  # 0 if feasible

Tutorials

Step-by-step notebooks in the notebooks/tutorials/ folder:

Notebook Topic
01_equality_constraints.ipynb Supervised fitting with a nonlinear equality constraint (unit circle)
02_inequality_constraints.ipynb Supervised fitting with inequality bounds via Fischer-Burmeister
03_parametric_optimization.ipynb Self-supervised parametric optimization - mixed equality + inequality, MLP comparison

How it works

ENFORCE appends an AdaNP module to any backbone network. Each NP layer solves a linearized QP: given the backbone output ŷ and the constraint Jacobian B = ∂c/∂y|_{x,ŷ}, the closed-form correction step is

ỹ = (I − Bᵀ(BBᵀ)⁻¹B) ŷ + Bᵀ(BBᵀ)⁻¹v,   with  v = Bŷ − c(x, ŷ)

This is the solution to the locally linearized projection problem and corresponds to a Gauss-Newton SQP step (without Hessian of constraints). AdaNP stacks NP layers adaptively until ‖c(x, ỹ)‖ < ε or max_it is reached, relinearizing at each iterate. The Jacobian B is computed via automatic differentiation through the constraint function c only (not the backbone), or taken from an analytical Jacobian given with the constraint, so the per-step cost is O(N_C³).

Scope of the ε-feasibility claim. For equality constraints affine in y, a single NP step achieves exact feasibility. Inequalities are reformulated with the Fischer-Burmeister function, which is nonlinear even for affine g, so they may need more steps. For nonlinear constraints, convergence is local: it requires the backbone prediction to lie in a neighborhood of the constraint manifold where LICQ holds and the linearization is accurate. If the backbone is far from feasibility or the Jacobian is ill-conditioned, residuals may remain above ε within the depth cap.

Inequality constraints g(x,y) ≤ 0 are reformulated using the Fischer-Burmeister function

φ_FB(λᵢ, −gᵢ) = √(λᵢ² + gᵢ² + ε_FB) − λᵢ + gᵢ = 0

which encodes the KKT complementarity conditions as an equality. AdaNP then operates in the extended space [y, λ] without any modification to the core algorithm.

Training. ENFORCE is trained with standard Adam on a loss ℓ = ℓ_task + λ_D ‖ŷ − ỹ‖² + λ_C ‖c(x,ỹ)‖, where the displacement penalty λ_D ‖ŷ − ỹ‖² encourages the backbone to produce predictions already close to the constraints manifold, reducing the depth needed at inference. An adaptive activation heuristic (inspired by trust-region methods) enables AdaNP only when the projection improves a task-specific loss measure, providing an automatic warm-up phase before constrained learning begins.

Reproducing paper results

The original benchmark datasets can be downloaded from here and placed in data/raw. The original benchmark training results can be downloaded from here (7.6 GB).

All benchmarks are run through scripts/run_benchmark.py. Select the problem by setting PROBLEM in benchmark_problems/config_benchmarking.py, then run from the repo root:

python scripts/run_benchmark.py

Function fitting (equality constraint)

# benchmark_problems/config_benchmarking.py
PROBLEM = "function_fitting"
MODEL   = "BOTH"   # trains ENFORCE and MLP baseline

To reproduce the hyperparameter study (sweep over λ_D and ε_T):

bash scripts/run_hyperparameter_study.sh

Function fitting with inequality constraints

PROBLEM = "sin_ineq"
MODEL   = "BOTH"

Engineering case studies

PROBLEM = "extraction_column"   # extractive distillation surrogate
# or
PROBLEM = "pooling"             # pooling problem (equality + inequality)
MODEL   = "BOTH"

Both require the datasets from Iftakher et al.. Download it from here. Place the CSV files under data/raw/<problem>/ as expected by the data paths in config_benchmarking.py.

Parametric optimization benchmarks

PROBLEM = "nonconvex_linear"     # nonconvex objective, linear equality constraints
# or
PROBLEM = "nonconvex_nonlinear"  # nonconvex objective, nonlinear equality constraints
MODEL   = "ENFORCE"

These problems require pre-generated data files. Download them and place them under data/raw/<problem>/ following the filenames in config_benchmarking.py. Data can be generated from the DC3 repository.

General settings

Key flags in benchmark_problems/config_benchmarking.py:

Flag Default Effect
MODEL "BOTH" "ENFORCE", "MLP", or "BOTH"
N 5 number of independent runs
PLOT True save result figures
SAVE True save model weights and metrics
FIX_SEED False fix random seed across runs

Contributing and support

Questions, bug reports and feature requests go to GitHub issues. To contribute code, see CONTRIBUTING.md for the development setup, the checks to run and how to open a pull request.

Contributors

Giacomo Lastrucci LinkedIn Google Scholar
Daniel Schicksnus LinkedIn
Artur M. Schweidtmann LinkedIn Google Scholar

Acknowledgements

This research is supported by Shell Global Solutions International B.V., for which we express sincere gratitude.

License

This work is licensed under a MIT license - see LICENSE.

Technische Universiteit Delft hereby disclaims all copyright interest in the program “ENFORCE” written by Artur M. Schweidtmann. Paulien Herder, Dean of Applied Science

Copyright (C) 2025 Artur Schweidtmann, TU Delft

👨‍💼 Contact & Support

Dr. Artur Schweidtmann
Process Intelligence Research
TU Delft

Connect with us:

X (Twitter)

LinkedIn


Built with ❤️ by the Process Intelligence Research team at TU Delft

Metadata

Release files for enforce-nn 2.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for enforce-nn 2.0.0
File Size Uploaded Provenance Actions
enforce_nn-2.0.0.tar.gz 8.9 MB Without provenance Details for enforce_nn-2.0.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for enforce-nn 2.0.0
File Interpreter ABI Platform Actions
enforce_nn-2.0.0-py3-none-any.whl Python 3 none any Details for enforce_nn-2.0.0-py3-none-any.whl

Total release size: 9.0 MB

Release files / enforce_nn-2.0.0.tar.gz

Download URL enforce_nn-2.0.0.tar.gz
Size 8.9 MB
Tags Source
SHA-256 checksum
How to use checksums
fc91dc7b38b8d362b222e2f7540078b32b8896a1df4bb5d9b62c24b8995f36b6
BLAKE2b-256 checksum
How to use checksums
fcd2624598374fe4018ea9103b0fe527286f6939e5f93ef58bdaee83362cf027
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.11

Release files / enforce_nn-2.0.0-py3-none-any.whl

Download URL enforce_nn-2.0.0-py3-none-any.whl
Size 40.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c812fbc3675a2085f8643329e07fc2350b758ee978ea32e4e2f8c723ff460580
BLAKE2b-256 checksum
How to use checksums
ee965bac04cd9073c3f78c6e3be4088c4355ac0e8055f952861a9ecbc488ebc5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.11

Security for enforce-nn 2.0.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page