Convolution on generated data¶
Four small scripts that each take a minute or less and need no download. They cover the
convolutional models that MNIST does not: 1-D sequences, 3-D volumes and
convolutional regression, plus a controlled look at what position_encoding actually does. See
Convolution for the algorithm.
Translation invariance¶
Full script: examples/shapes_conv.py.
A hollow 3 × 3 circle or a 3 × 3 cross sits at a random position in a 12 × 12 image — 100 possible positions. A flat machine has to memorise all of them; a convolutional one learns one clause.
import torch, torchtsetlin as tt
tt.seed_everything(0)
device = "cuda" if torch.cuda.is_available() else "cpu"
x_train, y_train = tt.data.make_shapes(2000, size=12, seed=0) # (N, 1, 12, 12) Boolean
x_test, y_test = tt.data.make_shapes(1000, size=12, seed=1)
model = tt.ConvTsetlinMachine(2, n_clauses=40, T=15, s=3.9, patch_size=3,
position_encoding=False, n_states=100)
model.class_names = ["circle", "cross"]
trainer = tt.Trainer(model, device=device, batch_size=16)
trainer.fit((x_train, y_train), epochs=10, val_data=(x_test, y_test))
print(trainer.test((x_test, y_test))) # {'test_accuracy': 1.0}
# Clauses 0..39 vote for 'circle', 40..79 for 'cross'; rank them by how often they are right.
score = tt.interpret.clause_precision(model, x_test, y_test)
cross = (model.clause_class == 1) & (model.clause_polarity > 0) & (score["support"] > 0.05)
best = int(torch.where(cross, score["precision"], torch.zeros_like(score["precision"])).argmax())
print(model.clause_patch(best)) # 1 = pixel on, -1 = off, 0 = don't care
print(model.clause_region(best)) # {'y': (0, 9), 'x': (0, 9)} — matches anywhere
The winner is not the whole cross but a fragment of it — and that is the point. The
script prints it with # = pixel must be on, . = must be off, blank = don't care:
clause 40 of 'cross': fires on 43% of images, 100% of them class 'cross'
allowed window rows y=(0, 9), columns x=(0, 9)
|###|
|.# |
|...|
Three horizontally adjacent on pixels never occur in a hollow circle (.#. / #.# / .#.),
so the cross's arm plus an empty row below already separates the classes perfectly, and the
window range says the clause may match at any of the 100 positions. Nothing forces a clause
to learn the complete template when a shorter one is already precise.
Run python examples/shapes_conv.py --flat to train both models on the same images. The
convolutional one scored 1.000 on every seed tried; the flat one, with the same clause
budget, lands anywhere between 0.55 and 0.91.
Position literals are not free
position_encoding=True lets a clause demand where it matches. On a task where the
shape can sit anywhere that is only a way to overfit: accuracy drops from 1.000 to about
0.5 (--position-encoding). Digits are the opposite case — see
MNIST — so check this flag before suspecting
the feedback code.
Sequences and signals¶
Full script: examples/conv1d_ramps.py.
Conv1dTsetlinMachine expects (B, Z, L): Z Boolean channels over L timesteps. A
thermometer encoder with flatten=False produces (N, L, n_bits), so one permute puts the
bit planes where the channels belong.
import torch, torchtsetlin as tt
# 48-sample signals of uniform noise with one 8-sample ramp hidden at a random offset:
# rising = class 1, falling = class 0 (see make_ramps() in the script).
x_train, y_train = make_ramps(3000, seed=0)
x_test, y_test = make_ramps(1000, seed=1)
enc = tt.data.ThermometerEncoder(n_bits=8, strategy="uniform",
value_range=(0.0, 1.0), flatten=False)
b_train = enc(x_train).permute(0, 2, 1).contiguous() # (N, 8, 48)
b_test = enc(x_test).permute(0, 2, 1).contiguous()
model = tt.Conv1dTsetlinMachine(2, n_clauses=50, T=30, s=5.0, kernel_size=8,
position_encoding=False, n_states=100)
trainer = tt.Trainer(model, device=device, batch_size=32)
trainer.fit((b_train, y_train), epochs=10, val_data=(b_test, y_test))
print(trainer.test((b_test, y_test))) # {'test_accuracy': 0.996} from the script
A clause is a (n_bits, 1, kernel_size) pattern, so printing its bit planes with the highest
threshold on top draws the waveform it detects — here, a rising staircase:
clause 51 of 'rising': fires on 12% of signals, 100% of them class 'rising'
........
.......#
.....##
#....###
#....###
#...####
#..#####
#.######
Volumes (3D)¶
Full script: examples/shapes_conv3d.py.
make_3d_shapes puts a 3 × 3 × 3 shape at a random position in an 8 × 12 × 12 volume. Class
0, the spindle, stacks the slices plus, dot, plus. Class 1, the 3D cross, stacks dot,
plus, dot. Every slice is a plus or a dot in both classes, so only the order along depth
tells them apart.
import torch, torchtsetlin as tt
x_train, y_train = tt.data.make_3d_shapes(2000, size=(8, 12, 12), seed=0) # (N, 1, 8, 12, 12)
x_test, y_test = tt.data.make_3d_shapes(1000, size=(8, 12, 12), seed=1)
model = tt.Conv3dTsetlinMachine(2, n_clauses=20, T=15, s=10, patch_size=3,
position_encoding=False, n_states=100)
trainer = tt.Trainer(model, device=device, batch_size=16)
trainer.fit((x_train, y_train), epochs=10, val_data=(x_test, y_test))
print(trainer.test((x_test, y_test))) # {'test_accuracy': 1.0}
--baselines trains two alternatives with the same clause budget. One is the 2D machine on
the slices stacked as channels, (8, 12, 12), whose window spans the whole depth. The other
is a flat machine. Over training seeds 0–4:
| model | test accuracy |
|---|---|
Conv3dTsetlinMachine, 3 × 3 × 3 window |
1.000 on all five seeds |
ConvTsetlinMachine, depth as channels |
0.828 – 0.984 |
TsetlinMachine (1 152 features) |
0.726 – 0.796 |
The stacked model has to learn every shape once per depth offset (six here); the 3D window
learns it once. The script prints each class's most precise clause slice by slice
(# = must be on, . = must be off). The spindle's clause is the lower half of a plus, a dot,
and the lower half of a plus again, which is a fragment of the spindle that never occurs in a
3D cross:
clause 0 of 'spindle': fires on 47% of volumes, 100% of them 'spindle' region={'d': (0, 5), 'y': (0, 9), 'x': (0, 9)}
d=0 d=1 d=2
|...| |...| |...|
|.#.| |...| |.#.|
|###| |.#.| |###|
--plot saves the same patches with tt.viz.plot_conv_clauses, which draws a 3D clause as
its kd slices side by side. With --position-encoding accuracy falls to 0.48, the same
trap as in 2D.
Convolutional regression¶
Full script: examples/conv_regression_blobs.py.
ConvRegressionTsetlinMachine maps the vote sum linearly onto y_range instead of taking an
argmax. Here each image holds one solid square of side 2–6 and the target is that side
length — a quantity that does not depend on position, so position_encoding=False again.
import torch, torchtsetlin as tt
x_train, y_train = make_squares(4000, seed=0) # (N, 1, 12, 12) Boolean, float targets
x_test, y_test = make_squares(1000, seed=1)
model = tt.ConvRegressionTsetlinMachine(
n_clauses=400, T=400, s=5.0, patch_size=7, # the window must be wider than the square
position_encoding=False,
weighted=True, # one clause can vote several times
y_range=(2.0, 6.0), n_states=100,
)
trainer = tt.Trainer(model, device=device, batch_size=16)
trainer.fit((x_train, y_train), epochs=30, val_data=(x_test, y_test))
print(trainer.test((x_test, y_test)))
# {'test_mae': 0.20, 'test_rmse': 0.27, 'test_r2': 0.96} from the script
Each clause adds weight * (y_max - y_min) / T to the prediction when it matches — 0.01 per
unit of weight at T=400 over a range of 4 — and its patch shows what it is keying on. The
heaviest clause of a default run requires a three-pixel vertical run in one column and every
other pixel of the 7 × 7 window to be off — a three-tall edge with nothing else beside it:
clause 379: weight 47, 3 pixels required on, 46 required off
#......
#......
#......
.......
.......
.......
.......
Read the next few and the tidiness stops: clauses 274 and 89 carry almost as much weight on a scatter of unrelated pixels. A regression TM spreads its answer over hundreds of clauses, and only some of them are individually legible.
weighted and T have to be chosen together
At these settings weighted=True is not a refinement but a requirement. Unweighted
clauses each contribute at most 1, so 400 of them can only reach a vote sum of 400 if
every clause fires — and with T=400 the same run collapses to R² −0.19. Either keep
the weights, or drop T to roughly half the clause count (unweighted with T=200
reaches R² 0.83).