Typed Tensors
Typed tensors provide an alternative API for which tensor characteristics are encoded in the type of the tensor. The Hasktorch library is layered such that [ TODO ]
Using typed tensors increases the expressiveness of program invariants that can be automatically checked by GHC at the cost of needing to be more explicit in writing code and also requiring working with Haskell's type-level machinery. Type-level Haskell programming has arisen through progressive compiler iteration leading to mechanisms that have a higher degree of complexity compared to value-level Haskell code or other languages such as Idris which were designed with type-level computations from inception. In spite of these compromises, Haskell offers enough capability to express powerful type-level representations of models that is matched by few other languages used for production applications.
Things we can do in typed Hasktorch:
specify, check, and infer tensor shapes at compile time
specify, check, and infer tensor data types at compile time
specify, check, and infer tensor compute devices at compile time
We can encode all Hasktorch tensor shapes on the type level using type-level lists and natural numbers:
type EmptyShape = '[]
type OneDimensionalShape (a :: Nat) = '[a]
type TwoDimensionalShape (a :: Nat) (b :: Nat) = '[a, b]
...
Tensor data types and compute device types are lifted to the type
level using the DataKinds language extension:
type BooleanCPUTensor (shape :: [Nat]) = Tensor '(CPU, 0) 'Bool shape
type IntCUDATensor (shape :: [Nat]) = Tensor '(CUDA, 1) 'Int64 shape
Devices are represented as tuples consisting of a DeviceType (here
CPU for the CPU and CUDA for a CUDA device, respectively) and a
device id (here the Nats 0 and 1, respectively).
It is a common misconception that specifying tensor properties at compile time is only possible if all tensor properties are constants and are statically known. If this were the case, then we could only write functions over fully specified tensors, say,
boring :: BooleanCPUTensor '[] -> BooleanCPUTensor '[]
boring = id
Fortunately, Haskell has the ability to reason about type variables. This feature is called parametric polymorphism. Consider this simple example of a function:
tensorNoOp
:: forall (shape :: [Nat]) (dtype :: DType) (device :: (DeviceType, Nat))
. Tensor device dtype shape
-> Tensor device dtype shape
tensorNoOp = id
Here, shape, dtype, and device are type variables that have been
constrained to be of kind shape, data type, and device, respectively.
The universal quantifier forall implies that this function is
well-defined for all inhabitants of the types that are compatible with
the type variables and their constraints.
The tensorNoOp function may seem trivial, and that is because its
type is very strongly constrained: Given any typed tensor, it must
return a tensor of the same shape and with the same data type and on
the same device. Besides by means of the identity, id, there are not
many ways in which this function can be implemented.
There is a connection between a type signature of a function and
mathematical proofs. We can say that the fact that a function exists
is witnessed by its implementation. The implementation, tensorNoOp =
id, is the proof of the theorem stated by tensorNoOp's type
signature. And here we have a proof that we can run this function on
any device and for any data type and tensor shape.
This is the essence of typed Hasktorch. As soon as the compiler gives its OK, we hold a proof that our program will run without shape or CUDA errors.
Autograd with typed tensors
Chapter 4 introduced automatic differentiation through the untyped API. The typed API exposes the same machinery — the same ATen tape runs underneath — but the types document and enforce the calling convention that the untyped API leaves implicit.
Trainable values are Parameter device dtype shape, the typed
counterpart of the untyped IndependentTensor:
w <- makeIndependent =<< randn @'[2, 1] @'D.Float @'( 'D.CPU, 0)
let w' = toDependent w -- Tensor '( 'D.CPU, 0) 'D.Float '[2, 1], tracked
Gradients are computed with grad:
grad :: Tensor device dtype '[] -> a -> b
Two things are worth reading off this signature. First, the loss
argument has shape '[]: the type demands a scalar. Calling
autograd on a non-scalar — a runtime error (or a silent
implicit-sum surprise) in dynamic frameworks — does not compile
here. Second, grad loss (flattenParameters model) returns an
HList of gradient tensors whose shapes are, by type, exactly the
shapes of the parameters they belong to. A shape mismatch between a
parameter and its gradient update cannot be expressed.
Models are ordinary records of typed layers; deriving Generic and
Parameterized gives parameter traversal for free, and HasForward
states the shape contract of the forward pass:
data MLP ... = MLP
{ layer0 :: Linear inputFeatures hiddenFeatures dtype device,
layer1 :: Linear hiddenFeatures outputFeatures dtype device
}
deriving (Show, Generic, Parameterized)
instance ... => HasForward (MLP ...)
(Tensor device dtype '[batchSize, inputFeatures])
(Tensor device dtype '[batchSize, outputFeatures])
where
forward MLP {..} = forward layer1 . tanh . forward layer0
Random initialization comes from Randomizable (sample MLPSpec),
and a training step is one call:
(model', optim') <- runStep model optim loss learningRate
where optim is mkGD, mkGDM, or mkAdam, and
loss = mseLoss @ReduceMean actual expected is again forced by its
type to be a scalar on the right device and dtype. The complete
program is in examples/static-xor-mlp.
The end-to-end guarantee is the one this chapter has been building towards: if the program compiles, the forward pass, the loss, every gradient, and every optimizer update agree about shapes, dtype, and device — the whole training loop, not just individual operations.