Concepts¶
The core idea¶
A ReLU network is piecewise linear. Every input point x activates some neurons (pre-activation value > 0) and silences others (pre-activation ≤ 0), producing a binary activation pattern across every hidden layer. All points that share the exact same pattern experience the same fixed linear function f(x) = Ax + b — the network is literally a matrix multiply plus bias in that region, with no nonlinearity in play.
The set of all points sharing a given activation pattern forms a convex polytope (a bounded or unbounded convex region defined by a finite set of halfspace constraints). These polytopes tile the input space without overlap: every input point belongs to exactly one region. The collection of all such polytopes is the polyhedral partition of the network.
parx computes this partition. Knowing it lets you ask questions that are otherwise opaque:
- How many distinct linear pieces does this network have? (complexity)
- Are there neurons that never fire across the whole input domain? (dead neurons)
- How large or small are the regions? (Chebyshev radii, volume estimates)
- How does the partition change as the network trains? (epoch-over-epoch analysis)
- What is the exact halfspace representation of a given region? (geometry)
Inputs¶
The network¶
parx accepts a ReLU neural network in four forms:
| Form | Example |
|---|---|
PyTorch nn.Module |
model = nn.Sequential(nn.Linear(2, 8), nn.ReLU(), nn.Linear(8, 1)) |
PyTorch state_dict |
model.state_dict() |
Path to .pth file |
"checkpoints/epoch_10.pth" |
Path to .h5 file |
"model.h5" (requires pip install parx[h5]) |
Important constraint: parx currently only handles sequential stacks of Linear → ReLU layers. Residual connections, skip connections, batch normalization, convolutional layers, and attention are not supported. Attempting to load such a model will either raise an error or silently drop the unsupported layers.
By default the output layer is excluded (include_output_layer=False) because it does not participate in the piecewise-linear structure — only hidden ReLU layers define the polyhedral partition.
The sample points¶
X # shape (N, input_dim), dtype float64
Sample points serve different purposes depending on the enumeration method (see below). They do not need to cover the whole input space, and they do not need to be on a grid.
The two enumeration strategies¶
This is the most important choice a user makes. The two strategies have fundamentally different guarantees.
Sparse enumeration — method="sparse_julia" or "sparse_python"¶
What it does: Runs a forward pass on every point in X. Each point lands in one region (its activation pattern). The result is the set of distinct patterns seen across all N points, one region per pattern, with the centroid set to the mean of all points in that pattern.
What you get: Only the regions that your data points happened to land in. If a region is small, low-density, or simply not covered by your sample, it will not appear in the partition.
When to use it:
- You want a fast survey — which regions exist in the neighborhood your data occupies?
- You have a large, dense point cloud that likely covers the relevant input space.
- You are willing to miss small or remote regions in exchange for speed.
input_dimcan be large; the cost scales withNand the number of neurons, not with the number of regions.
Cost: O(N × total neurons) — embarrassingly parallel, fast even for deep networks.
X = np.random.uniform(-1, 1, (10_000, input_dim))
partition = compute_partition(model, X, method="sparse_julia")
Exact enumeration — method="exact_julia", "exact_julia_fast", or "exact_python"¶
What it does: Starts from a single point x0 = data[0] and performs a depth-first search (DFS) over the graph of adjacent regions. Two regions are adjacent if they share a facet (a face of codimension 1). At each candidate facet the algorithm solves a linear program (LP via HiGHS) to determine whether the neighboring region is feasible and to find its Chebyshev center.
What you get: The complete partition — every region reachable from x0 by crossing facets. For a connected network this is provably all regions.
When to use it:
- You need the complete picture, not just a sample.
input_dimis small (2 is ideal; up to 4–5 is often feasible).- The network is shallow (1–3 hidden layers, small width).
- You can afford minutes of compute time.
Cost: Exponential in the worst case. A 2×5×5×5 network (input_dim=2, three layers of 5 neurons each) typically has hundreds of regions and takes seconds. A 2×20×20 network may have thousands of regions and take minutes. Beyond that, exact enumeration is currently impractical.
x0 = np.zeros((1, input_dim)) # only data[0] is used
partition = compute_partition(model, x0, method="exact_julia")
Note
The exact methods populate Region.active_indices (non-redundant constraint indices) and Region.bounded (whether the polytope is bounded). The sparse methods do not.
Outputs — the Partition object¶
compute_partition returns a Partition: a flat list of Region objects plus the network weights and biases.
Region¶
| Field | Type | Description |
|---|---|---|
activation_path |
list[np.ndarray[bool]] |
One boolean array per hidden layer; True = neuron active |
centroid |
np.ndarray shape (input_dim,) |
An interior point (Chebyshev center) |
active_indices |
np.ndarray[int32] or None |
Non-redundant constraint row indices (exact only) |
bounded |
bool |
Whether the polytope is bounded (exact only) |
Partition methods¶
# Geometry
D, g = partition.halfspaces(region) # D @ x <= g defines the polytope
D, g = partition.halfspaces(region, active_only=True) # non-redundant rows only
A, b = partition.local_affine(region) # local linear map: f(x) = A @ x + b
# Routing
regions = partition.route(X) # list[Region | None] per point
# Filtering
subset = partition.regions_at_layer(l) # regions with path depth == l
# Metadata
partition.n_layers # number of hidden layers
partition.input_dim # input dimensionality
len(partition) # number of regions
Data pipeline¶
┌─────────────────────────────────────┐
│ Network source │
│ nn.Module / state_dict / .pth / .h5│
└──────────────────┬──────────────────┘
│
▼
load_network()
weights: list[ndarray]
biases: list[ndarray]
│
┌──────────┴──────────┐
│ │
▼ sparse ▼ exact
forward pass (N pts) DFS from x0
deduplicate patterns LP at each facet
O(N × neurons) O(regions × LP)
│ │
└──────────┬──────────┘
│
▼ Julia → Python boundary
RegionFindResult
patterns (n_regions, total_bits) int8
offsets (n_layers+1,) int64
centroids (n_regions, input_dim) float64
[active_indices, active_offsets,
bounded — exact methods only]
│
▼
Partition.from_result()
│
▼
Partition
┌─────────┼─────────────────┐
│ │ │
▼ ▼ ▼
analyze visualize persist
stats Plotly/mpl figs save_partition()
load_partition()
.npz
The critical boundary is the Python↔Julia bridge: Julia does all combinatorially intensive work (forward passes, DFS, LP solves) and returns plain numeric arrays. Python rebuilds the Partition from those arrays using only NumPy — no Julia call is needed after compute_partition returns.
Partition.halfspaces() reconstructs D, g on the fly from the activation path and network weights. This is pure NumPy arithmetic — fast and allocation-light.
Architecture — the Python/Julia split¶
The design separates concerns sharply:
| Layer | Language | Responsibility |
|---|---|---|
| User API | Python | compute_partition, Partition, analysis, viz |
| Combinatorial search | Julia | Forward passes, DFS, LP solves |
| Bridge | juliacall | Zero-copy NumPy↔Julia array passing |
| Geometry queries | Python (NumPy) | halfspaces(), local_affine(), routing |
| Visualization | Python (Plotly default, matplotlib optional) | All plotting; backend= selects engine |
| Serialization | Python (NumPy .npz) |
Save/load, no Julia at load time |
Julia is only needed during compute_partition. Everything after — analysis, visualization, verification, serialization — is pure Python and runs without Julia. A partition saved to .npz can be loaded and analyzed in an environment without Julia installed.
Julia threads default to all available cores. Override with JULIA_NUM_THREADS=4 python script.py.
For a runnable, worked version of all of this, see the Notebooks page.