The Recurrence
A task that needs memory, and the layer that carries state forward
Images have neighbours in two directions and convolution exploits that. Sequences have one direction and an order that carries meaning, and they have a second property images do not: they vary in length. A recurrent layer handles both by processing one element at a time and carrying a state forward.
A task that needs memory
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
def make_sequences(n, length=8, seed=0):
"""Sequences of digits 1 to 9, balanced by construction.
The label is 1 if the digit at position 0 appears again later. The
rest of the sequence is drawn from the other eight digits, and for
the positive half the first digit is planted at one random later
position, so the classes are exactly balanced.
A bag of counts cannot answer it. Both classes contain repeated
digits, and counts never record which digit came first. Answering it
means holding position 0 in memory until the sequence ends."""
g = torch.Generator().manual_seed(seed)
first = torch.randint(1, 10, (n, 1), generator=g)
rest = torch.randint(1, 9, (n, length - 1), generator=g)
rest = rest + (rest >= first).long() # 1 to 9, excluding first
y = torch.randint(0, 2, (n,), generator=g)
where = torch.randint(0, length - 1, (n, 1), generator=g)
idx = torch.arange(length - 1).unsqueeze(0)
plant = (y.unsqueeze(1) == 1) & (idx == where)
rest = torch.where(plant, first.expand_as(rest), rest)
return torch.cat([first, rest], dim=1), y
X_tr, y_tr = make_sequences(8000, seed=0)
X_va, y_va = make_sequences(2000, seed=1)
train_loader = DataLoader(TensorDataset(X_tr, y_tr), batch_size=64,
shuffle=True)
val_loader = DataLoader(TensorDataset(X_va, y_va), batch_size=512)
print('training sequences', tuple(X_tr.shape))
print('class balance %.3f' % y_tr.float().mean())
print()
for i in range(4):
seq = X_tr[i].tolist()
print('%s first=%d repeated later: %s'
% (''.join(str(d) for d in seq), seq[0], bool(y_tr[i])))
class balance 0.509
95626647 first=9 repeated later: False
16633531 first=1 repeated later: True
37574514 first=3 repeated later: False
74238582 first=7 repeated later: False
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
def make_sequences(n, length=8, seed=0):
"""Sequences of digits 1 to 9, balanced by construction.
The label is 1 if the digit at position 0 appears again later. The
rest of the sequence is drawn from the other eight digits, and for
the positive half the first digit is planted at one random later
position, so the classes are exactly balanced.
A bag of counts cannot answer it. Both classes contain repeated
digits, and counts never record which digit came first. Answering it
means holding position 0 in memory until the sequence ends."""
g = torch.Generator().manual_seed(seed)
first = torch.randint(1, 10, (n, 1), generator=g)
rest = torch.randint(1, 9, (n, length - 1), generator=g)
rest = rest + (rest >= first).long() # 1 to 9, excluding first
y = torch.randint(0, 2, (n,), generator=g)
where = torch.randint(0, length - 1, (n, 1), generator=g)
idx = torch.arange(length - 1).unsqueeze(0)
plant = (y.unsqueeze(1) == 1) & (idx == where)
rest = torch.where(plant, first.expand_as(rest), rest)
return torch.cat([first, rest], dim=1), y
X_tr, y_tr = make_sequences(8000, seed=0)
X_va, y_va = make_sequences(2000, seed=1)
train_loader = DataLoader(TensorDataset(X_tr, y_tr), batch_size=64,
shuffle=True)
val_loader = DataLoader(TensorDataset(X_va, y_va), batch_size=512)
from sklearn.linear_model import LogisticRegression
import numpy as np
# A bag of counts: how many of each digit, order discarded.
def counts(x):
return torch.stack([(x == d).sum(dim=1) for d in range(1, 10)],
dim=1).float().numpy()
model = LogisticRegression(max_iter=1000).fit(counts(X_tr), y_tr.numpy())
print('bag of counts, logistic regression %.4f'
% model.score(counts(X_va), y_va.numpy()))
print('always predicting the majority class %.4f'
% max(y_va.float().mean().item(), 1 - y_va.float().mean().item()))
print('\nthe counts do not say which digit came first, so they cannot')
print('separate the classes. Order is the entire signal here.')
always predicting the majority class 0.5020
the counts do not say which digit came first, so they cannot
separate the classes. Order is the entire signal here.
The recurrence, written out
torch.manual_seed(0)
d_in, d_hidden = 4, 3
Wx = torch.randn(d_in, d_hidden) * 0.5
Wh = torch.randn(d_hidden, d_hidden) * 0.5
b = torch.zeros(d_hidden)
def run(sequence):
h = torch.zeros(d_hidden)
for x in sequence:
h = torch.tanh(x @ Wx + h @ Wh + b) # the entire recurrence
return h
a = torch.randn(1, d_in)
c = torch.randn(1, d_in)
print('state after [a, c]', run(torch.cat([a, c])).round(decimals=3).tolist())
print('state after [c, a]', run(torch.cat([c, a])).round(decimals=3).tolist())
print('\nsame two inputs, different order, different state.')
print('that is the whole reason this layer exists.')
state after [c, a] [0.8899999856948853, -0.13199999928474426, -0.9919999837875366]
same two inputs, different order, different state.
that is the whole reason this layer exists.
PyTorch does the loop for you
from torch import nn
torch.manual_seed(0)
rnn = nn.RNN(input_size=4, hidden_size=3, batch_first=True)
x = torch.randn(2, 6, 4) # batch 2, 6 steps, 4 features
output, final = rnn(x)
print('input ', tuple(x.shape))
print('output ', tuple(output.shape), ' the state at every step')
print('final ', tuple(final.shape), ' the state after the last step')
print('\nthey agree:',
bool(torch.allclose(output[:, -1, :], final[0])))
output (2, 6, 3) the state at every step
final (1, 2, 3) the state after the last step
they agree: True
batch_first is not the default
PyTorch's recurrent layers expect (sequence, batch, features) unless you pass batch_first=True, which gives you the (batch, sequence, features) that every other layer in the library uses. Forgetting it does not raise an error when your batch size and sequence length happen to be compatible, it just trains on transposed data. Always pass it.
Day 1 takeaway
A recurrent layer applies the same weights at every position and carries a state forward, so order changes the result. It returns the state at every step and the state after the last one. Passbatch_first=True so the shapes match the rest of your code.