Deep Learning in Racket with Malt: From XOR to a Two-Tower Recommendation System
Why neural networks and why XOR first?
A single linear model can only draw straight boundaries. Feed it the four
input/output pairs of the XOR function, like: (0,0)→0, (0,1)→1, (1,0)→1, (1,1)→0
and it fails no matter how long you train it, because no straight line
separates the 1s from the 0s. This was the famous critique that stalled
neural network research in 1969, and it has a famous resolution: stack
layers with a non-linear function (here, the rectifier
, or ReLU)
between them. One hidden layer is enough to bend the decision boundary
around the XOR pattern.
That is the thread running through this chapter’s three examples:
- XOR: the smallest problem that requires a hidden layer.
- Circle classification: a two-hidden-layer network learning a curved boundary from synthetic data we generate ourselves.
- A two-tower recommender: a jointly learned model that takes two kinds of input (customer features and product features), learns an embedding for each, and predicts a rating. Along the way we hit, diagnose, and fix a real bug: and the debugging trail is as instructive as the model itself.
The Malt mental model
Malt is the deep learning library built for The Little Learner (the third book in the Little Schemer series). Three ideas suffice for everything in this chapter:
- Tensors. Nested arrays of numbers: a scalar is rank 0, a vector rank 1, a matrix rank 2.
(tensor 1.0 2.0)builds one;(tref t i)indexes it. -
Layers as functions of two arguments. A layer such as
reluis a function that takes the input tensor and returns a function of theta (the parameter list). A network is then just function composition, andblock/stack-blockspackage layers together with their parameter shapes:1 (block relu (list (list 8 2) (list 8))) ; weights (8×2) + bias (8)
- Gradient descent.
l2-lossturns a network into a loss function,sampling-objmakes it stochastic (mini-batches), andnaked-gradient-descentoptimizes it using automatic differentiation (∇). Hyperparameters (e.g.,revs(iterations),alpha(learning rate),batch-size) are set withwith-hypers.
Every malt function used in this chapter, defined
So that no listing below sends you reaching for a browser, here is the complete vocabulary of the chapter. Functions are explained at first use as well; treat this as the reference you can flip back to.
Building and inspecting tensors
(tensor ...): constructs a tensor from numbers or other tensors.(tensor 1.0 2.0)is a rank-1 vector;(tensor (tensor 1 2) (tensor 3 4))is a 2×2 matrix. Tensors must be rectangular: every row the same length.(list->tensor lst): converts a (possibly nested) list into a tensor. This is the workhorse for programmatic data generation: build a list withfor/list, then convert.(tref t i): tensor ref: elementioft.(tref (tensor 7 8 9) 1)is8. Works at any rank: on a matrix it returns a row.(tlen t): tensor length: the size of the outermost dimension.(shape t): the dimensions as a list, e.g.(400 7)for a data tensor of 400 samples of 7 features.(concat u v): concatenates two rank-1 tensors end to end. Differentiable, so gradients flow through it.(dot-product-2-1 W t): matrix × vector:Wis rank 2,trank 1, and each row ofWis dotted witht. This is the “multiply by the weight matrix” half of every neural network layer.(flatten t): collapses a tensor to rank 1.
Layers and networks
linear:the affine layer.((linear t) theta)computesW·t + b, wherethetais the list(weights bias). (You will never write the multiplication yourself.)rectify: the ReLU activation,
, applied elementwise.
relu:linearfollowed byrectify: the standard fully-connected layer used in all three examples.(block fn shape-list): bundles a layer function with the shapes of its parameters.(block relu (list (list 8 2) (list 8)))means: arelulayer whose theta is an 8×2 weight matrix plus a bias vector of 8.(stack-blocks (list b1 b2 ...)): composes blocks left to right into a network, wiring the output of each block into the next and concatenating their parameter lists.(block-fn network): extracts the composed function from a stacked network: something you can call as((fn input) theta).(block-ls network): extracts the list of parameter shapes from a network. You need it to create correctly-shaped initial parameters; for the XOR network it returns((8 2) (8) (1 8) (1)), e.g., weight matrix and bias for the hidden layer, then weight matrix and bias for the output layer.
Parameters, training, and prediction
(init-theta shapes): creates fresh parameters with the given shapes: random small weights, zero biases. This is the starting point that gradient descent improves.(l2-loss fn): given a network function, returns a loss function of the training data and theta: the average squared difference between the network’s predictions and the targets. “L2” refers to the L2 (Euclidean) norm, the straight-line distance between two vectors. Concretely, for a batch of
samples, where
is the network’s prediction and
the true target, the L2 loss is the mean of squared errors:

Squaring does two jobs at once: it makes every error positive (over- and
under-predictions can’t cancel), and it penalizes large errors much more
than small ones: a prediction off by 1.0 costs 100× more than one off by
0.1, so gradient descent is pushed hardest exactly where the model is
most wrong. Just as important for us, the square function has a simple,
smooth derivative everywhere, which is what makes the loss a good target
for automatic differentiation. (Malt also offers cross-entropy-loss for
probability outputs and kl-loss for matching distributions; L2 is the
natural choice here because our targets are plain numbers, not
probabilities.)
The value returned by l2-loss is not a number but a function waiting
for data: ((l2-loss fn) xs ys) is itself a function of theta, and that
is the object gradient descent knows how to minimize.
(sampling-obj loss xs ys): wraps the loss so each evaluation uses a random mini-batch drawn fromxs/ys(stochastic gradient descent). The batch size comes from thebatch-sizehyperparameter.(naked-gradient-descent objective theta0): plain SGD. Repeatedly compute the gradient ofobjectivewith respect to theta and steps downhill. It returns the trained theta. (“Naked” distinguishes it from the momentum/velocity/RMSProp/Adam variants malt also provides.)(with-hypers ((revs n) (alpha a) (batch-size b)) body): runsbodywith hyperparameters in scope:revsis the number of gradient steps,alphathe learning rate,batch-sizethe mini-batch size.(model fn theta): freezes trained parameters into a bare prediction function:((model fn theta) input)is the network applied to one input, no theta argument needed.(∇ f theta)(alsogradient-of): automatic differentiation. The gradient of scalar functionfwith respect to theta. Used internally by gradient descent; used by us directly when debugging.(dual? x)/(ρ x): malt implements AD by wrapping numbers in duals that carry a derivative. Trained thetas and model outputs can contain duals;dual?detects them andρ(“rho”) extracts the plain numeric value. The examples define(define (realize x) (if (dual? x) (ρ x) x))for this.
List helpers malt provides (used for walking the parameter list):
ref/tref-style indexing with (ref lst i), (refr lst i) (“ref rest”:
drop the first i elements), and len. In the recommender,
(refr theta 2) means “theta starting at the product tower’s parameters”.
Install malt once with:
1 raco pkg install --auto malt
2 raco setup malt
Example 1: XOR, the smallest non-linear problem
The network is 2→8→1: eight ReLU units in one hidden layer, one output unit. The training set is the entire function: four input vectors and their targets:
1 (define xor-xs
2 (tensor (tensor 0.0 0.0)
3 (tensor 0.0 1.0)
4 (tensor 1.0 0.0)
5 (tensor 1.0 1.0)))
6
7 (define xor-ys
8 (tensor (tensor 0.0)
9 (tensor 1.0)
10 (tensor 1.0)
11 (tensor 0.0)))
Read the network definition as a data-flow diagram: stack-blocks wires
two relu blocks in series, and block-ls recovers the parameter shapes
from that definition; for this network, ((8 2) (8) (1 8) (1)). That shape
list is exactly what init-theta needs to manufacture a random starting
theta, so the network architecture is written down once and everything
else is derived from it. Training is then a single
naked-gradient-descent call over 4000 revisions with the whole dataset
(batch size 4) as each mini-batch: l2-loss builds the loss function from
the network, sampling-obj adapts it to mini-batch sampling, and
with-hypers supplies the knobs. The complete file, xor.rkt:
1 #lang racket
2
3 (require malt)
4
5 ;; Simple XOR network: 2 -> 8 -> 1
6
7 (define xor-network
8 (stack-blocks
9 (list
10 (block relu (list (list 8 2) (list 8))) ; hidden layer: 2 inputs, 8 units
11 (block relu (list (list 1 8) (list 1)))))) ; output layer: 8 inputs, 1 unit
12
13 (define xor-theta-shapes (block-ls xor-network))
14
15 (define xor-xs
16 (tensor (tensor 0.0 0.0)
17 (tensor 0.0 1.0)
18 (tensor 1.0 0.0)
19 (tensor 1.0 1.0)))
20
21 (define xor-ys
22 (tensor (tensor 0.0)
23 (tensor 1.0)
24 (tensor 1.0)
25 (tensor 0.0)))
26
27 (random-seed 42)
28
29 (define trained-theta
30 (with-hypers ((revs 4000)
31 (alpha 0.01)
32 (batch-size 4))
33 (naked-gradient-descent
34 (sampling-obj (l2-loss (block-fn xor-network)) xor-xs xor-ys)
35 (init-theta xor-theta-shapes))))
36
37 (define xor-model (model (block-fn xor-network) trained-theta))
38
39 (printf "XOR predictions (expect 0, 1, 1, 0):~%")
40 (for ((x (in-list (list (tensor 0.0 0.0) (tensor 0.0 1.0)
41 (tensor 1.0 0.0) (tensor 1.0 1.0)))))
42 (printf " ~a -> ~a~%" x (xor-model x)))
Running it
1 $ racket xor.rkt
2 XOR predictions (expect 0, 1, 1, 0):
3 (tensor 0.0 0.0) -> (tensor 1.3877787807814457e-16)
4 (tensor 0.0 1.0) -> (tensor 0.9999999999999998)
5 (tensor 1.0 0.0) -> (tensor 0.9999999999999998)
6 (tensor 1.0 1.0) -> (tensor 5.551115123125783e-17)
Interpretation
The outputs are 0.000… and 0.999…: the network has learned XOR
exactly, to floating-point precision. Two practical notes. First, ReLU
networks can collapse at initialization (all units dead, every prediction
identical); if you see that, change the random seed or lower the learning
rate. Second, malt prints some startup noise ("settings=" hash lines);
filter it with 2>/dev/null if it bothers you.
Example 2: Two hidden layers learn a curved boundary
XOR’s boundary problem is tiny. A more realistic test: scatter 300 random points in the square [-1,1]×[-1,1] and label each 1 if it lies inside a circle of radius 0.6, 0 otherwise. A representative sample of the data as generated (two coordinates in, one label out):
1 x = (tensor 0.213 -0.764) y = (tensor 0.0) ; 0.213² + 0.764² > 0.36
2 x = (tensor -0.105 0.331) y = (tensor 1.0) ; inside the circle
The network is 2→8→8→1, with two hidden layers. Depth matters here: the first
layer can carve the plane into half-planes; the second can combine those
into polygonal regions that approximate the circle. The data generator and
the rest of two_hidden_layers.rkt:
1 #lang racket
2
3 (require malt)
4
5 ;; Two hidden layers: 2 -> 8 -> 8 -> 1
6 ;; Synthetic task: classify whether a 2-D point lies inside a circle of radius 0.6
7
8 (define circle-network
9 (stack-blocks
10 (list
11 (block relu (list (list 8 2) (list 8))) ; hidden layer 1
12 (block relu (list (list 8 8) (list 8))) ; hidden layer 2
13 (block relu (list (list 1 8) (list 1)))))) ; output layer
14
15 (define circle-theta-shapes (block-ls circle-network))
16
17 ;; Generate 300 synthetic training points in [-1,1] x [-1,1]
18 (define num-samples 300)
19
20 (random-seed 7)
21
22 (define circle-xs
23 (list->tensor
24 (for/list ((_ (in-range num-samples)))
25 (tensor (- (* 2.0 (random)) 1.0) (- (* 2.0 (random)) 1.0)))))
26
27 (define circle-ys
28 (list->tensor
29 (for/list ((i (in-range num-samples)))
30 (let ((x (tref (tref circle-xs i) 0))
31 (y (tref (tref circle-xs i) 1)))
32 (tensor (if (< (+ (* x x) (* y y)) 0.36) 1.0 0.0))))))
33
34 (random-seed 1) ; re-seed so initial weights are reproducible regardless of data generation
35
36 (define trained-theta
37 (with-hypers ((revs 8000)
38 (alpha 0.005)
39 (batch-size 16))
40 (naked-gradient-descent
41 (sampling-obj (l2-loss (block-fn circle-network)) circle-xs circle-ys)
42 (init-theta circle-theta-shapes))))
43
44 (define circle-model (model (block-fn circle-network) trained-theta))
45
46 ;; Quick self-check: accuracy on the training data
47 (define correct
48 (for/sum ((i (in-range num-samples)))
49 (let ((pred (tref (circle-model (tref circle-xs i)) 0))
50 (truth (tref (tref circle-ys i) 0)))
51 (if (equal? (> pred 0.5) (> truth 0.5)) 1 0))))
52
53 (printf "Training accuracy: ~a/~a~%" correct num-samples)
54
55 (printf "Sample predictions:~%")
56 (for ((x (in-list (list (tensor 0.0 0.0) (tensor 0.9 0.9)
57 (tensor 0.3 0.2) (tensor -0.8 0.5)))))
58 (printf " ~a -> ~a~%" x (tref (circle-model x) 0)))
The label generator deserves a close look: for each of the 300 samples,
tref twice digs out the coordinates: (tref circle-xs i) is the i-th
sample (a 2-vector), and a second tref picks the x or y coordinate; then
the label is 1.0 exactly when
. The self-check at the
bottom applies the trained circle-model to every training point and
counts how often the thresholded prediction (> pred 0.5) matches the
label; that for/sum pattern (model in, count out) is the minimal
viable test harness for every network in this chapter.
Two idioms worth noting. The data is built with plain Racket for/list
loops wrapped in list->tensor, the most reliable way to construct tensors
programmatically in malt. And we call (random-seed 1) again right before
init-theta, because data generation consumed the random stream; re-seeding
makes the initial weights reproducible.
Running it
1 $ racket two_hidden_layers.rkt
2 Training accuracy: 299/300
3 Sample predictions:
4 (tensor 0.0 0.0) -> 1.149080417254465
5 (tensor 0.9 0.9) -> 0.0
6 (tensor 0.3 0.2) -> 1.167167163862036
7 (tensor -0.8 0.5) -> 0.0
Interpretation
299 of 300 training points are classified correctly (predictions are thresholded at 0.5). The sample predictions show the learned geometry: the origin (0,0) and the inner point (0.3, 0.2), both inside the circle, score above 1, while the far corner (0.9, 0.9) and the point (-0.8, 0.5) (radius ≈ 0.94, outside) score 0. ReLU networks are free to overshoot the nominal [0,1] range; nothing in the architecture clamps the output, and with l2 loss it doesn’t need to.
Example 3: A jointly learned two-tower recommender
The idea
Real recommendation systems rarely see one flat feature vector. They see different kinds of things (a customer and a product) and must model the interaction between them. The standard trick is to give each input type its own sub-network (“tower”) that compresses raw features into a learned embedding, then combine the embeddings to produce a score. Both towers are trained jointly: the same gradient signal shapes what the model learns about customers and about products.
Our synthetic world: customers are described by 3 numbers in [0,1): how much of a bargain hunter, quality seeker, and novelty seeker they are. Products get 4 numbers: cheapness (inverse price), quality, popularity, and novelty. The hidden ground truth is a taste match:

Note the multiplications: a rating depends on products of customer and product features. Popularity is deliberately unused, a decoy feature the model should learn to ignore. A sample of the generated data (400 samples, inputs concatenated into one 7-element vector, customer first):
1 x = (tensor 0.94 0.19 0.34 | 0.27 0.42 0.71 0.83) y ≈ 0.94
2 x = (tensor 0.05 0.73 0.90 | 0.11 0.65 0.22 0.77) y ≈ 1.43
The architecture
The input is one 7-element tensor. Malt has no slice operation and tensors
must be rectangular (so we can’t pass a ragged pair of vectors), so we slice
with constant 0/1 selection matrices: multiplying the input by a 3×7
identity-prefix matrix extracts the customer features, and a 4×7
identity-suffix matrix extracts the product features. A dot-product with a
constant matrix is differentiable, so gradients flow through the slices.
Each half then goes through its own linear+rectify tower (producing a
4-element embedding), the embeddings are concatenated, and a final linear
layer emits the rating.
One malt subtlety appears in the code below: trained parameters and model
outputs can contain duals (malt’s automatic-differentiation wrappers), so
we unwrap them with ρ before doing plain arithmetic, and we import a few
racket/base operators under aliases (r+, r-, …) for scalar
bookkeeping, since malt redefines + - * / as binary differentiable tensor
operations.
The complete joint_recommendation.rkt:
1 #lang racket
2
3 (require malt)
4 (require (only-in racket/base [+ r+] [- r-] [/ r/] [abs r-abs]))
5
6 ;; Jointly learned recommendation model.
7 ;; Each sample combines TWO kinds of input:
8 ;; a. customer features (bargain-hunter, quality-seeker, novelty-seeker) -> 3 numbers
9 ;; b. product features (inverse-price, quality, popularity, novelty) -> 4 numbers
10 ;; A "customer tower" and a "product tower" each learn an embedding;
11 ;; the embeddings are concatenated and a final layer predicts the rating.
12
13 (define cust-dim 3)
14 (define prod-dim 4)
15 (define input-dim 7) ; cust-dim + prod-dim (literal: malt's + makes duals)
16 (define emb-dim 4)
17
18 ;; Slice out the two input types with constant 0/1 selection matrices
19 ;; (malt has no slice op; dot-product with a constant matrix is differentiable).
20 (define (selection-matrix n offset)
21 (list->tensor
22 (for/list ((i (in-range n)))
23 (list->tensor
24 (for/list ((j (in-range input-dim)))
25 ;; NB: base + via r+ and equal? -- malt's + breaks eq? here
26 (if (equal? j (r+ i offset)) 1.0 0.0))))))
27
28 (define select-customer (selection-matrix cust-dim 0))
29 (define select-product (selection-matrix prod-dim cust-dim))
30
31 ;; Custom block function.
32 ;; theta layout: [cust-W cust-b prod-W prod-b out-W out-b]
33 (define rec-block-fn
34 (λ (t)
35 (λ (theta)
36 (let ((cust-emb (rectify ((linear (dot-product-2-1 select-customer t)) theta)))
37 (prod-emb (rectify ((linear (dot-product-2-1 select-product t)) (refr theta 2)))))
38 ((linear (concat cust-emb prod-emb)) (refr theta 4))))))
39
40 (define rec-theta-shapes
41 (list (list emb-dim cust-dim) (list emb-dim) ; customer tower
42 (list emb-dim prod-dim) (list emb-dim) ; product tower
43 (list 1 8) (list 1))) ; output layer (8 = 2 x emb-dim)
44
45 ;;*------ Synthetic training data ------
46 ;; Hidden "ground truth": rating = customer tastes . matching product attrs + noise.
47
48 (define num-samples 400)
49
50 (random-seed 5)
51
52 (define (random-vec n)
53 (list->tensor (for/list ((_ (in-range n))) (random))))
54
55 (define rec-xs
56 (list->tensor
57 (for/list ((_ (in-range num-samples)))
58 (concat (random-vec cust-dim) (random-vec prod-dim)))))
59
60 (define rec-ys
61 (list->tensor
62 (for/list ((i (in-range num-samples)))
63 (let ((x (tref rec-xs i)))
64 (tensor (+ (+ 0.1
65 (* 0.8 (+ (+ (* (tref x 0) (tref x 3)) ; bargain-hunter x cheap
66 (* (tref x 1) (tref x 4))) ; quality-seeker x quality
67 (* (tref x 2) (tref x 6))))) ; novelty-seeker x novel
68 (* 0.05 (- (random) 0.5))))))))
69
70 (random-seed 5) ; re-seed so initial weights are reproducible
71
72 (define trained-theta
73 (with-hypers ((revs 8000)
74 (alpha 0.01)
75 (batch-size 16))
76 (naked-gradient-descent
77 (sampling-obj (l2-loss rec-block-fn) rec-xs rec-ys)
78 (init-theta rec-theta-shapes))))
79
80 (define rec-model (model rec-block-fn trained-theta))
81
82 ;; trained theta holds duals; unwrap to plain numbers
83 (define (realize x) (if (dual? x) (ρ x) x))
84
85 ;; Quick self-check: mean absolute error on training data
86 (define total-error
87 (for/fold ((acc 0.0)) ((i (in-range num-samples)))
88 (r+ acc (r-abs (r- (realize (tref (rec-model (tref rec-xs i)) 0))
89 (realize (tref (tref rec-ys i) 0)))))))
90
91 (printf "Mean absolute error over ~a samples: ~a~%"
92 num-samples (r/ total-error num-samples))
93
94 ;; Recommend: score a new customer against three candidate products
95 (define new-customer (tensor 0.9 0.8 0.1)) ; bargain hunter + quality seeker
96
97 (define candidate-products
98 (list (tensor 0.9 0.9 0.2 0.1) ; cheap, high quality
99 (tensor 0.1 0.9 0.9 0.9) ; expensive, high quality, popular, novel
100 (tensor 0.5 0.2 0.1 0.9))) ; mid price, low quality, novel
101
102 (printf "Predicted ratings for new customer ~a:~%" new-customer)
103 (for ((p (in-list candidate-products)))
104 (printf " product ~a -> ~a~%" p
105 (realize (tref (rec-model (concat new-customer p)) 0))))
Because this network is a custom composition rather than a
stack-blocks chain, we manage theta explicitly: rec-theta-shapes
declares the six parameter tensors (two per layer), and refr in the block
function walks the parameter list: theta for the customer tower,
(refr theta 2) for the product tower, (refr theta 4) for the output
layer.
Walk through rec-block-fn one expression at a time. The customer half of
the input is (dot-product-2-1 select-customer t), a 3-element vector
pulled out of the 7-element input. ((linear ...) theta) applies the
customer tower’s affine transform (consuming theta[0], the 4×3 weights,
and theta[1], the bias), and rectify makes it non-linear: the result is
cust-emb, a 4-element learned embedding of the customer. The product tower
is identical except it reads (refr theta 2), the parameter list with its
first two elements skipped, so linear there sees theta[2] and theta[3]
as its weights and bias. (concat cust-emb prod-emb) glues the two
4-element embeddings into one 8-element vector, and the final linear over
(refr theta 4) reduces that to a single number: the predicted rating.
Every operation in the chain is differentiable, so naked-gradient-descent
shapes all six parameter tensors from the same loss signal. The two towers
and the head learn jointly, each adapting to what the others produce.
The scoring of a new customer at the end shows the intended use of a
recommender: the customer vector is fixed, and we concatenate it with each
candidate product in turn ((concat new-customer p) rebuilds the 7-element
input layout the towers expect), reading off a predicted rating per product.
Running it
1 $ racket joint_recommendation.rkt
2 Mean absolute error over 400 samples: 0.09034948309654235
3 Predicted ratings for new customer (tensor 0.9 0.8 0.1):
4 product (tensor 0.9 0.9 0.2 0.1) -> 1.0315383522030774
5 product (tensor 0.1 0.9 0.9 0.9) -> 1.020786966658098
6 product (tensor 0.5 0.2 0.1 0.9) -> 0.8800861176716633
Interpretation
The mean absolute error of 0.09 on targets ranging from ~0.16 to ~1.61 means
the model captures the taste-match structure well. For the new customer (a
bargain hunter (0.9) and quality seeker (0.8) who doesn’t care about novelty
(0.1)) the model scores the cheap, high-quality product highest and the
mid-priced, low-quality novelty item lowest, exactly as the ground-truth
formula dictates. It slightly over-values the expensive popular product
(the ground truth says 0.82 vs. the predicted 1.02): a concat-then-linear
head can only approximate multiplicative interactions, so some residual
distortion is expected. If we needed a tighter fit, the principled move
would be to score with a dot-product of the two embeddings, the classic
matrix-factorization head, which matches the bilinear structure of the data
exactly.
Interlude: the bug that made the model learn nothing
The first version of the recommender trained to MAE 0.25 and predicted the same constant (~0.72) for every product; essentially the mean rating. The debugging trail is worth following because each step is a general technique:
- Check the data first.
sanity_check.rktrecomputed targets by hand with base arithmetic and verified shapes, values, and distribution, all correct. (The synthetic data generator is included in this directory for exactly this purpose.) - Check the gradients. Evaluating
(∇ ...)on a single sample showed every weight gradient exactly 0.0 while bias gradients were fine, identically across all inputs. Identical zeros are never dead ReLUs; they mean structure, not luck. - Bisect the composition. Each primitive (
dot-product-2-1,concat,linear) passed gradients in isolation, and the two-tower architecture worked with hand-written literal matrices, which pointed at the matrix construction.
The culprit was one line in selection-matrix:
1 (if (eq? j (+ i offset)) 1.0 0.0) ; WRONG under (require malt)
Malt redefines + as a differentiable operator whose results are never
eq? to plain integers, so the condition was silently always false and
the “selection matrices” were all zeros. The towers received zero vectors;
with malt’s zero-initialized biases, the embeddings rectified to 0; every
weight gradient vanished; only the final bias could move, and a lone bias
can only learn the mean. The fix uses base operators for index arithmetic:
(equal? j (r+ i offset)). MAE dropped from 0.25 to 0.09 with no other
change.
The general lesson: when a model collapses to predicting the mean, suspect
the gradient path before the hyperparameters. And in malt, remember that
+ - * / abs min max sub1 are no longer the racket/base versions.
Malt survival guide (hard-won)
- malt shadows
+ - * / abs min max sub1with binary, differentiable tensor ops.(+ a b c)fails deep inD-extend.rkt;sub1rejects inexact numbers. Alias base ops (r+etc.) for scalar bookkeeping. - Never compare the result of malt arithmetic with
eq?. - Model outputs and trained parameters may be duals. Unwrap with
(if (dual? x) (ρ x) x)before plain math. - Tensors must be rectangular; use
list->tensor+for/listto build them, and constant selection matrices to slice them. - Re-seed right before
init-thetafor reproducible runs; if training collapses to a constant, try another seed or a smalleralphabefore redesigning the network.
Files in this directory
| File | Contents |
|---|---|
xor.rkt |
2→8→1 network learns XOR exactly |
two_hidden_layers.rkt |
2→8→8→1 network, circle classification, 299/300 |
joint_recommendation.rkt |
Two-tower jointly learned recommender, MAE 0.09 |
sanity_check.rkt |
Independent verification of the synthetic data |
PROBLEMS.md |
Full postmortem of the selection-matrix bug and malt gotchas |