Networks as Arrows
Composing layers with . works until the architecture stops being a
straight line: skip connections, parallel branches, multi-input
blocks. Those are exactly what Haskell's Arrow vocabulary is for,
and Torch.Typed.NN.Arrow makes networks arrows: >>> chains, &&&
fans out, *** runs in parallel, and proc notation wires
arbitrary graphs.
import Control.Arrow
import Control.Category (id, (>>>))
import GHC.Generics (Generic)
import Torch (Tensor)
import qualified Torch.Functional as UF
import qualified Torch.Tensor as UT
import qualified Torch.Typed as T
import Torch.Typed.NN.Arrow
import Torch.Typed.NN.BatchNorm
import Prelude hiding (id)
A network is a stochastic function
newtype Net x y = Net { runNet :: x -> IO y }
Net has real Category and Arrow instances. Pure typed
operations lift with arr, whole modules with layer (which uses
their stochastic forwardStoch), and IO layers embed directly.
Because composition is value level — the middle type of
f >>> g is fixed by the values f and g — chains of convolutions
type-check without any partial type signatures: each combinator's
output shape is computed from its input shape by the same type
families the rest of the typed API uses.
Skip connections
A ResNet residual is the arrow idiom:
residual f = (id &&& f) >>> arr (uncurry (+))
skip <- pure (residual (arr (T.mulScalar (2 :: Float))) :: Net (T.Tensor '( 'T.CPU, 0) 'T.Float '[2, 3]) (T.Tensor '( 'T.CPU, 0) 'T.Float '[2, 3]))
out1 <- runNet skip T.ones
The result is 2x + x = 3 everywhere:
T.toDynamic out1 =>
Tensor Float [2,3] [[ 3.0000 , 3.0000 , 3.0000 ],
[ 3.0000 , 3.0000 , 3.0000 ]]
residualWith takes a projection for the skip path, which is how
ResNet's stride-2 downsampling blocks are wired (see
test/Torch/Typed/ResNetSpec.hs for a complete miniature ResNet).
proc notation
Multi-branch blocks read like their architecture diagrams. An Inception-style block runs parallel convolutions at different receptive fields and concatenates the channels; here is its one-dimensional cousin, live:
let incept :: Net (T.Tensor '( 'T.CPU, 0) 'T.Float '[2]) (T.Tensor '( 'T.CPU, 0) 'T.Float '[2])
incept = proc x -> do
a <- arr (T.mulScalar (10 :: Float)) -< x
b <- residual id -< x
returnA -< a + b
out2 <- runNet incept T.ones
a contributes 10x, b contributes x + x, so:
T.toDynamic out2 =>
Tensor Float [2] [ 12.0000 , 12.0000 ]
The branches a and b both consume x; the desugarer builds the
&&&/*** plumbing.
Batch normalization: honestly stateful
Batch norm updates its running statistics in place during training
— it never was a pure function, and Net's IO makes that explicit
instead of hiding it. Torch.Typed.NN.BatchNorm keeps weight and
bias as typed Parameters and the running statistics as mutable
buffers:
bn <- T.sample (BatchNorm2dSpec :: BatchNorm2dSpec 3 'T.Float '( 'T.CPU, 0))
x3 <- T.randn :: IO (T.Tensor '( 'T.CPU, 0) 'T.Float '[4, 3, 8, 8])
before <- UF.clone (case bnRunningMean bn of UT.MutableTensor t -> t)
_ <- runNet (Net (batchNorm2dForward bn True)) x3
after <- UF.clone (case bnRunningMean bn of UT.MutableTensor t -> t)
The running mean starts at zero and moves after one training-mode forward pass — same record, mutated buffer:
before =>
Tensor Float [3] [ 0.0000, 0.0000, 0.0000]
after =>
Tensor Float [3] [-2.5258e-3, 4.6410e-4, -1.1079e-3]
In evaluation mode (batchNorm2dForward bn False) the statistics are
used but not touched.
Where the parameters live
Deliberately not inside the arrow. A model is an ordinary record —
data Block c = Block
{ k1 :: Conv2d c c 3 3 'T.Float device, n1 :: BatchNorm2d c 'T.Float device, ... }
deriving (Generic, Parameterized)
— with derived Parameterized and the usual runStep training loop,
and a plain function turns the record into wiring:
identityBlock train Block{..} = residual (arr (conv2dForward ... k1) >>> Net (batchNorm2dForward n1 train) >>> ...).
The earlier feature/arrow experiment stored the composition in the
type (Conv2d ... :>>> MaxPool :>>> ...) and paid for it: every
composition point was an ambiguous type for the compiler, deriving
Parameterized broke, and examples needed PartialTypeSignatures.
Records for state, arrows for wiring — each side does what it is good
at.
Folding a stack of layers
Net is a real Category, and that pays off when a model is a
stack of same-shaped blocks: composition has an identity, so a whole
list of layers folds into one network.
let blocks = map (\k -> residual (arr (T.mulScalar (k :: Float))))
[1, 2, 3] :: [Net (T.Tensor '( 'T.CPU, 0) 'T.Float '[2]) (T.Tensor '( 'T.CPU, 0) 'T.Float '[2])]
stack = foldr (>>>) id blocks
out3 <- runNet stack T.ones
Each block computes x + k*x = (1+k)x, so the folded stack multiplies
by 2 * 3 * 4:
T.toDynamic out3 =>
Tensor Float [2] [ 24.0000 , 24.0000 ]
The same fold works when every layer has its own parameter record in
an HList — the seed is still id, the combining step is still
>>>, just expressed with hfoldr and an Apply' instance.
In the wild
For a full-scale example, see gpt2-haskell by Collin Arnett, whose GPT-2 decoder stack is wired exactly in this style —
residual (layer ln >>> selfAttention mha mask) >>> transformerMLP
— with the causal mask written as its tabulate formula and typed
slicing (getSlice) replacing runtime shape proofs that previously
needed unsafeCoerce. Its migration PR is a good study in porting an
existing model to these APIs.