Tensor

The core autograd engine. Wraps a numpy array, tracks every op into a graph, and computes gradients on .backward() via reverse topological sort.

x = Tensor([1.0, 2.0], requires_grad=True)
y = (x * 2).sum()
y.backward()
x.grad  # [2. 2.]

Arithmetic

# +
(Tensor([1.0,2.0]) + Tensor([3.0,4.0])).data   # [4. 6.]

# - / neg
(Tensor([5.0]) - Tensor([2.0])).data              # [3.]

# *
(Tensor([2.0,3.0]) * Tensor([4.0,5.0])).data   # [8. 15.]

# /
(Tensor([6.0]) / Tensor([2.0])).data              # [3.]

# **
(Tensor([3.0]) ** 2).data                      # [9.]

# matmul / @
x = Tensor(np.random.randn(2,3))
w = Tensor(np.random.randn(3,4))
(x.matmul(w)).shape   # (2, 4)
(x @ w).shape          # same, (2, 4)

Reduction

# sum
Tensor([[1.0,2.0],[3.0,4.0]]).sum(axis=1).data   # [3. 7.]

# mean
Tensor([2.0,4.0,6.0]).mean().data   # 4.0

# max
Tensor([1.0,5.0,3.0]).max().data   # 5.0

# argmax - no grad, inference only
Tensor([[0.1,0.7,0.2]]).argmax(axis=1)   # array([1])

Activation

# tanh
Tensor([0.0,1.0]).tanh().data

# relu
Tensor([-1.0,2.0]).relu().data   # [0. 2.]

# softmax - sums to 1 along axis
Tensor([[1.0,2.0,3.0]]).softmax(axis=-1).data

# gelu - smooth, nonzero grad for negative x
Tensor([-1.0,0.0,1.0]).gelu().data

Elementwise math

# log
Tensor([1.0, np.e]).log().data   # [0. 1.]

# sqrt
Tensor([4.0]).sqrt().data   # [2.]

# clip
Tensor([0.5,5.0,-1.0]).clip(0.0,1.0).data   # [0.5 1.  0. ]

# log_softmax - stable log(softmax(x))
Tensor([[1.0,2.0]]).log_softmax(axis=-1).data

Shape

# reshape / view
Tensor(np.zeros((2,3,4))).reshape(2,12).shape   # (2, 12)
Tensor(np.zeros((2,3,4))).view(2,12).shape     # same as reshape

# flatten
Tensor(np.zeros((2,3,4,4))).flatten(start_dim=1).shape   # (2, 48)

# transpose / permute
Tensor(np.zeros((2,3,4))).transpose(0,2,1).shape   # (2, 4, 3)
Tensor(np.zeros((2,3))).permute(1,0).shape         # (3, 2)

Indexing / gather-scatter

# __getitem__ - gather rows (used by Embedding)
w = Tensor(np.arange(9.0).reshape(3,3), requires_grad=True)
w[[0,2]].shape   # (2, 3)

# topk - returns (values, indices)
vals, idx = Tensor([[1.0,5.0,3.0,2.0]]).topk(2, axis=1)
vals.data   # [[5. 3.]]
idx         # [[1 2]]

# scatter - write src into copy of self at index positions
base = Tensor(np.zeros((3,4)), requires_grad=True)
idx = np.array([[0],[1],[2]])
src = Tensor(np.ones((3,1)), requires_grad=True)
base.scatter(1, idx, src)

# index_add - scatter-add (used to combine MOE expert outputs)
base2 = Tensor(np.zeros(3), requires_grad=True)
src2 = Tensor(np.array([1.0,2.0]), requires_grad=True)
base2.index_add(0, np.array([0,0]), src2).data   # [3. 0. 0.]

Combine / split

# cat - static method
a = Tensor(np.zeros((2,3)), requires_grad=True)
b = Tensor(np.zeros((2,3)), requires_grad=True)
Tensor.cat([a,b], axis=1).shape   # (2, 6)

# stack - static method, adds a new axis
Tensor.stack([a,b], axis=0).shape   # (2, 2, 3)

# split - inverse of cat
c = Tensor(np.zeros((2,6)), requires_grad=True)
parts = c.split(3, axis=1)   # two tensors, each (2, 3)

Sampling (no grad)

# multinomial - sample class indices from probabilities
Tensor([[0.0,1.0,0.0]]).multinomial(num_samples=1)   # array([[1]])

Convolution and pooling

# conv2d - (N,C,H,W) x (out_c,C,kh,kw)
x = Tensor(np.random.randn(1,3,8,8), requires_grad=True)
k = Tensor(np.random.randn(4,3,3,3), requires_grad=True)
x.conv2d(k, stride=1, padding=1).shape   # (1, 4, 8, 8)
x.conv2d(k, padding=1, pad_mode="reflect").shape   # same shape, mirrored border

# maxpool2d
Tensor(np.random.randn(1,2,4,4)).maxpool2d(2).shape   # (1, 2, 2, 2)

# avgpool2d
Tensor(np.ones((1,2,4,4))).avgpool2d(2).data   # all 1.0
Non-constant pad_mode values (reflect, replicate, circular) are exact in the forward pass, but backward crops the gradient rather than scattering it back through the padding rule - a documented approximation. Indexing ops use np.add.at for backward, so repeated indices (e.g. duplicate tokens in an embedding lookup) accumulate correctly instead of overwriting.

Layers

Every layer holds a params dict of Tensor(requires_grad=True). No manual backward needed - autograd handles it once you call .backward() on the loss.

Linear

layer = Linear(4, 2)
x = Tensor(np.random.randn(5,4))
layer.forward(x).shape   # (5, 2)

Tanh

Tanh().forward(Tensor([-1.0,0.0,1.0])).data

ReLU

ReLU().forward(Tensor([-1.0,2.0])).data   # [0. 2.]

GELU

GELU().forward(Tensor([-1.0,0.0,1.0])).data

Conv2D

conv = Conv2D(in_channels=3, out_channels=8,
              kernel_size=3, padding=1, pad_mode="reflect")
x = Tensor(np.random.randn(2,3,16,16), requires_grad=True)
conv.forward(x).shape   # (2, 8, 16, 16)

pad_mode accepts constant (default, exact gradient), reflect, replicate, or circular.

Embedding

emb = Embedding(vocab_size=1000, embed_dim=32)
emb.forward([1, 5, 9]).shape   # (3, 32) - idx is a plain list, not a Tensor

TiedLinear

emb = Embedding(vocab_size=1000, embed_dim=32)
lm_head = TiedLinear(emb)   # shares emb's weight, transposed

hidden = emb.forward([1, 5, 9])
lm_head.forward(hidden).shape   # (3, 1000)

Weight tying shares the same Tensor object - gradients from both uses accumulate into one param, and the optimizer only updates it once.

LayerNorm

ln = LayerNorm(dim=16)
x = Tensor(np.random.randn(4,16), requires_grad=True)
ln.forward(x).shape   # (4, 16)

BatchNorm2D

bn = BatchNorm2D(num_features=3)
x = Tensor(np.random.randn(4,3,8,8), requires_grad=True)
bn.forward(x)        # training mode - uses batch stats, updates running stats
bn.eval()
bn.forward(x)        # inference mode - uses running_mean / running_var
bn.train()

Dropout

do = Dropout(p=0.5)
x = Tensor(np.ones((10,10)))
do.forward(x)   # training - random mask, survivors scaled by 1/(1-p)
do.eval()
do.forward(x)   # identity
do.train()

MaxPool2D

MaxPool2D(kernel_size=2).forward(Tensor(np.random.randn(1,3,8,8))).shape   # (1, 3, 4, 4)

AvgPool2D

AvgPool2D(kernel_size=2).forward(Tensor(np.ones((1,2,4,4)))).data   # all 1.0

Flatten

Flatten().forward(Tensor(np.zeros((2,3,4,4)))).shape   # (2, 48)

GlobalAvgPool

GlobalAvgPool().forward(Tensor(np.ones((2,3,5,5)))).shape   # (2, 3)

Loss functions

All return a scalar Tensor - call .backward() on the result.

MSE

pred = Tensor([1.0,2.0], requires_grad=True)
actual = Tensor([0.0,0.0])
loss = MSE().loss(pred, actual)
loss.backward()
pred.grad   # [2. 4.]

BinaryCrossEntropy

pred = Tensor([0.9,0.1], requires_grad=True)
actual = Tensor([1.0,0.0])
loss = BinaryCrossEntropy().loss(pred, actual)
loss.backward()

NLLLoss

log_probs = Tensor(np.log([[0.7,0.3],[0.2,0.8]]), requires_grad=True)
targets = np.array([0,1])   # plain int array, not a Tensor
loss = NLLLoss().loss(log_probs, targets)
loss.backward()

CrossEntropyLoss

logits = Tensor(np.random.randn(8,10), requires_grad=True)
targets = np.array([3,0,7,7,1,9,2,5])
loss = CrossEntropyLoss().loss(logits, targets)   # raw logits, not probabilities
loss.backward()
CrossEntropyLoss runs log_softmax internally - pass raw logits. Use NLLLoss only if you already have log-probabilities.

Optimizers

SGD

optimizer = SGD(lr=0.01)
loss.backward()
optimizer.step(net)   # param.data -= lr * param.grad

RMSprop

optimizer = RMSprop(lr=0.001, decay=0.9)
loss.backward()
optimizer.step(net)   # divides grad by running avg of squared grad

Adam

optimizer = Adam(lr=0.001, beta1=0.9, beta2=0.999)
for step in range(100):
    net.zero_grad()
    loss = MSE().loss(net.forward(x), y)
    loss.backward()
    optimizer.step(net)   # momentum + adaptive scaling, bias-corrected

AdamW

optimizer = AdamW(lr=0.001, weight_decay=0.01)
loss.backward()
optimizer.step(net)   # Adam + decoupled weight decay

NeuralNet

net = NeuralNet([Linear(2, 8), Tanh(), Linear(8, 1)])

net.forward(x)
list(net.params())   # yields every layer's params, in order
net.zero_grad()
net.save("model.picodl")  
net.load("model.picodl")   

Data

iterator = BatchIterator(batch_size=32, shuffle=True)
for batch in iterator(inputs, targets):
    batch.inputs, batch.targets  # raw numpy arrays

Train loop

train(net, inputs, targets,
      num_epochs=50,
      iterator=BatchIterator(batch_size=16),
      loss=MSE(),
      optimizer=Adam(lr=0.01))

net.save("model.picodl")

Accepts either a Tensor or raw numpy array for inputs/targets - unwrapped automatically before batching.

Targets are checked by dtype each batch: int-dtype targets (class indices, for CrossEntropyLoss / NLLLoss) are passed through raw; anything else (float targets, for MSE / BinaryCrossEntropy) gets wrapped in a Tensor. This matters because the classification losses do fancy indexing on the target array directly - passing a Tensor there instead of a raw int array breaks it.
# classification: y_train is an int array
train(net, X_train, y_train, loss=CrossEntropyLoss(), optimizer=AdamW())

# regression: y_train is a float array, wrapped in Tensor automatically
train(net, X_train, y_train, loss=MSE(), optimizer=Adam())