Menu
Version 0.0.2
GradieX — Python Library Reference
The gradiex module is a pybind11 extension wrapping the C++ engine
described in the C++ Library Reference. It is a single
compiled module with one class.
v0.0.2 is the middle release: an architecture rewrite that made the engine fast and, deliberately, much smaller. Most of what it lists as missing was present in v0.0.1 — mini-batching, dropout, L2, a validation split, seeded runs, multi-class softmax and model save/load — and v0.0.3 has since restored mini-batching and moved the engine to float. Of the three, this is the only release whose Python bindings build. See the version comparison.
Installation
Requires a C++20 compiler, Python 3.9 or later, and pybind11. From the repository root:
pip install . # normal install
pip install -e . # editable, for development
NumPy is not required to build, but the module returns NumPy arrays, so it is needed in practice. The engine itself is header-only and is compiled directly into the extension module; nothing else is linked.
To build a redistributable wheel:
pip wheel --no-deps -w dist .
Quick Start
import numpy as np
import gradiex
X = np.array([[0., 0.], [0., 1.], [1., 0.], [1., 1.]])
Y = np.array([[0.], [1.], [1.], [0.]])
net = gradiex.Network(learning_rate=0.5)
net.add_hidden(8, input_size=2, activation="tanh", init="xavier")
net.add_output(1, activation="sigmoid", loss="mse")
net.build()
history = net.train(X, Y, epochs=4000, shuffle=True, seed=42)
print(history[0], "->", history[-1]) # 0.30554 -> 0.000042
print(net.predict([1., 0.])) # [0.9932]
A network is declared, then built. Layers are added first; build()
constructs the engine and initializes the weights. Nothing can run before
build(), and no layers can be added after it.
Module Contents
gradiex.__version__- Version string, e.g.
"0.0.2". gradiex.__author__- Author string.
gradiex.activations()→list[str]- Names accepted by the
activationargument, sorted. gradiex.losses()→list[str]- Names accepted by the
lossargument, sorted. Includes the short aliases. gradiex.initializers()→list[str]- Names accepted by the
initargument, sorted. gradiex.Network- The only class. See below.
class gradiex.Network
Constructor
Network(learning_rate=0.01)- Creates an empty network.
learning_ratemust be greater than zero, orValueErroris raised.
Declaring Layers
add_hidden(width, input_size=None, activation="relu", init="he")- Appends a hidden layer of
widthneurons.input_sizeis required only for the first layer. On later layers it defaults to the previous layer's width, and if given explicitly it must equal that width.Must be called before
add_output(). add_output(width, input_size=None, activation="sigmoid", loss="mse", init="he")- Adds the output layer. May be called exactly once, and must come after all
hidden layers.
lossselects the loss function used byloss(),train(), and the backward pass. build()- Constructs the underlying engine and initializes all weights and biases.
Raises
RuntimeErrorif no output layer was added, or if already called. initialize()- Alias for
build().
Running the Network
forward(x)→numpy.ndarray- Runs one forward pass and returns the output activations as a fresh 1-D
float64array of lengthoutput_size.xmust be 1-D with exactlyinput_sizeelements. predict(x)→numpy.ndarray- Alias for
forward(). The engine has no dropout or other train-only behaviour, so the two are identical. backward(y)→None- Back-propagates one target and immediately applies an SGD update to every
layer.
forward()must have been called first, because the backward pass reads the cached activations from that call.ymust be 1-D with exactlyoutput_sizeelements. loss(x, y)→float- Runs a forward pass on
x, then returns the configured loss againsty. Note that this does perform a forward pass, so it overwrites the cached activations. train(X, Y, epochs=100, shuffle=False, seed=None, verbose=False, log_every=0)→list[float]-
Trains by per-sample stochastic gradient descent and returns the mean loss for
each epoch, computed with the configured loss function.
Argument Meaning X2-D array, shape (n_samples, input_size)Y2-D array, shape (n_samples, output_size)epochsNumber of passes over the whole dataset. Must be >= 0. shuffleReshuffle the sample order every epoch seedSeed for the shuffle only. Nonedraws fromrandom_device. This does not make weight initialization reproducible — the engine offers no control over that.verbosePrint progress to standard output log_everyPrint every nth epoch. 0 picks epochs / 10. Ignored unlessverboseis true.Every row of
XandYis length-checked before training starts, so a badly shaped dataset fails immediately rather than midway. An empty dataset, orepochs=0, returns an empty list and does nothing.The global interpreter lock is released for the duration of the training loop, so other Python threads continue to run.
Inspecting Weights
weights(layer)→numpy.ndarray- Returns a copy of one layer's weights, shaped
(width, input_size), soW[n][i]is the weight from inputito neuronn.Layers are indexed 0 upward through the hidden layers, with the output layer last. Negative indices count from the end, so
-1is the output layer.The array is a copy; writing to it does not affect the network.
biases(layer)→numpy.ndarray- Returns a copy of one layer's biases, a 1-D array of length
width. Same indexing rules.
Properties
| Property | Type | Access | Meaning |
|---|---|---|---|
learning_rate | float | read / write | May be changed at any time, including between training calls |
num_hidden | int | read | Number of hidden layers |
input_size | int | read | Fan-in of the first layer |
output_size | int | read | Width of the output layer. Requires build(). |
built | bool | read | Whether build() has been called |
repr() summarises the architecture:
>>> net
<gradiex.Network lr=0.5 2 -> 8[tanh] -> 1[sigmoid] loss=mse>
Network is not copyable. The underlying C++ object owns raw
pointers, so copying is disabled rather than left to double-free.
Activation, Loss and Initializer Names
Activations
| Name | Definition |
|---|---|
"sigmoid" | 1 / (1 + exp(-x)) |
"relu" | max(0, x) |
"leaky_relu" | x if x > 0 else 0.01 * x |
"tanh" | Hyperbolic tangent |
"swish" | x * sigmoid(x) |
"gelu" | GELU, tanh approximation |
"softmax" | Listed by activations() but rejected by
add_hidden() and add_output(): the engine has no
softmax derivative, so it cannot be trained through. Raises
ValueError. |
Losses
| Name | Alias | Notes |
|---|---|---|
"mse" | — | Mean squared error |
"mae" | — | Mean absolute error |
"huber" | — | Huber loss, delta fixed at 1.0 |
"binary_cross_entropy" | "bce" | For a sigmoid output |
"cross_entropy" | "cce" | Categorical. Intended for a softmax output, which is not yet trainable. |
Initializers
| Name | Standard deviation | Suits |
|---|---|---|
"he" | sqrt(2 / fan_in) | ReLU family. The default. |
"xavier" | sqrt(2 / (fan_in + fan_out)) | sigmoid, tanh |
"zeros" | — | All weights zero. For tests; a network initialized this way cannot learn, because every neuron in a layer stays identical. |
Biases always start at zero regardless of the initializer.
Names are used rather than function objects on purpose. The C++ API takes an activation and its derivative as two independent function pointers, so a mismatched pair compiles and trains silently wrong. Selecting by name guarantees a matched pair.
Array Conventions
- Inputs accept anything NumPy can cast to
float64: lists, tuples, and integer or single-precision arrays. Casting is automatic. - Non-contiguous arrays, such as a slice with a step, are copied as needed.
- All returned arrays are fresh, contiguous, 1-D or 2-D
float64arrays. They never alias the network's internal buffers, so the network cannot be modified by writing to them. XandYfortrain()must be 2-D even when there is a single feature or a single target. UseY.reshape(-1, 1)for a scalar target.
Errors Raised
The C++ engine can reach several states that crash the process or train
incorrectly. The binding collects layer specifications first and constructs the
engine only inside build(), which makes those states unreachable. Each
raises a normal Python exception instead:
| Condition | Exception |
|---|---|
| Unknown activation, loss or initializer name | ValueError (the message lists the valid names) |
"softmax" requested, which has no derivative | ValueError |
input_size omitted on the first layer | ValueError |
input_size does not match the previous layer's width | ValueError |
width or input_size not positive | ValueError |
learning_rate not greater than zero | ValueError |
| A hidden layer added after the output layer | RuntimeError |
| A second output layer added | RuntimeError |
build() with no output layer | RuntimeError |
build() called twice | RuntimeError |
A layer added after build() | RuntimeError |
Any operation before build() | RuntimeError |
x or y of the wrong length | ValueError |
x or y not 1-D; X or Y not 2-D | ValueError |
X and Y with different row counts | ValueError |
epochs negative | ValueError |
Layer index out of range in weights() or biases() | IndexError |
Only one output layer is permitted because the engine's
backwardPass propagates gradients from outputLayers[0] alone;
a second head would train silently incorrectly.
Differences from the C++ Engine
The binding deliberately does not match the C++ API in two places.
- Loss reporting uses the configured loss.
core::Network::computeLosssums squared error regardless of which loss function the layer holds, so training onbinary_cross_entropyand reading the loss from C++ reports a different quantity from the one being minimised.Network.loss()andNetwork.train()call the layer's actual loss function.train()is reimplemented.- It does not call
core::Network::train. The C++ version prints every epoch unconditionally, visits samples in fixed order, and returns nothing. The binding's version returns per-epoch loss history, supports seeded shuffling, is silent unless asked, and releases the GIL.
Everything else — the forward pass, the backward pass, the weight update, and the numerical behaviour of every activation, initializer and loss — is the C++ engine unchanged.
Examples
Binary classification with cross entropy
import numpy as np, gradiex
rng = np.random.default_rng(0)
X = rng.normal(size=(400, 4))
Y = ((X[:, 0] + X[:, 1] > 0).astype(float)).reshape(-1, 1)
net = gradiex.Network(learning_rate=0.05)
net.add_hidden(16, input_size=4, activation="relu", init="he")
net.add_hidden(16, activation="relu") # input_size inferred
net.add_output(1, activation="sigmoid", loss="bce")
net.build()
hist = net.train(X, Y, epochs=200, shuffle=True, seed=1,
verbose=True, log_every=50)
pred = np.array([net.predict(x)[0] for x in X])
print("accuracy", ((pred > 0.5) == (Y[:, 0] > 0.5)).mean())
Manual forward and backward
For finer control, drive one sample at a time. This is what
train() does internally.
net.build()
for epoch in range(1000):
total = 0.0
for x, y in zip(X, Y):
net.forward(x)
net.backward(y)
total += net.loss(x, y) # note: runs another forward pass
if epoch % 100 == 0:
print(epoch, total / len(X))
Be aware that loss() performs its own forward pass, so the loop above
runs two forward passes per sample. To avoid that, capture the loss from the
return value of train() instead.
Inspecting and saving weights
The engine has no save or load facility. NumPy can stand in for inspection, though there is currently no way to write the weights back:
np.savez("model.npz",
**{f"W{i}": net.weights(i) for i in range(net.num_hidden + 1)},
**{f"b{i}": net.biases(i) for i in range(net.num_hidden + 1)})
Changing the learning rate during training
for lr in (0.1, 0.05, 0.01):
net.learning_rate = lr
net.train(X, Y, epochs=100, shuffle=True, seed=7)
Tests
python python/test_bindings.py
Must be run from the repository root. The suite covers XOR training, reproducibility of the seeded shuffle, the manual forward/backward loop, NumPy casting, and every error in section 7.
Troubleshooting
AttributeError: module 'gradiex' has no attribute '__version__'- A directory or file named
gradiexearlier onsys.pathis shadowing the extension module. This happens most often when running a script from a directory that contains a checkout namedgradiex. Checkgradiex.__file__: it should end in.soor.pyd. RuntimeError: call build() before using the networkbuild()was never called, or an exception interrupted the declaration sequence. Checknet.built.- Loss does not decrease
- Check, in order: that the initializer is not
"zeros"; that the learning rate is not so large that the loss oscillates; that inputs are roughly unit-scale, since the engine does no normalization; and thatYis in the range the output activation can produce — asigmoidoutput cannot reach values outside(0, 1). - Compilation fails with errors about
<numbers> - The engine requires C++20. Confirm the compiler is recent enough;
setup.pyrequestscxx_std=20. - Results differ between runs with the same
seed - Expected.
seedcontrols only the shuffle order. Weight initialization draws from a shared generator seeded fromstd::random_deviceand cannot be controlled. See C++ Known Bugs, item 8.
Not Supported
The binding exposes what the engine provides and adds nothing numerical. So there is no momentum or Adam, no learning rate schedule, no mini-batching, no L2 or dropout, no train/validation split, no early stopping, no model save or load, no reproducible weight initialization, no trainable softmax output, and no convolution, recurrence, GPU or threading.
Single precision is not available either: the engine is double
throughout, and all arrays are float64.
See C++ Known Bugs and Limitations for defects
inherited from the engine, and TODO.md in the repository root for
planned performance work.