Lenses

Hasktorch uses lenses in two directions: inward, addressing parts of a single tensor (slices, named fields), and outward, traversing every tensor inside an arbitrary structure. Both come in untyped and typed flavors. This chapter walks through all four quadrants with executable examples.

import Torch
import Torch.Index (lslice, slice)
import Torch.Lens (types, flattenValues, replaceValues)
import Lens.Family
import GHC.Generics (Generic)
import qualified Torch.Typed as T
import qualified Torch.Typed.Lens as TL
import qualified Torch.Typed.Index as TI
import Data.Vector.Sized (Vector)

Slicing lenses (untyped)

The lslice quasiquoter turns PyTorch indexing syntax into a lens into a tensor: something you can read through, but also write through.

let t = asTensor ([[0, 1, 2], [3, 4, 5]] :: [[Float]])

Reading a slice with ^.:

t ^. [lslice|1|] :: Tensor => 
Tensor Float [3] [ 3.0000   ,  4.0000   ,  5.0000   ]
t ^. [lslice|:, ::2|] :: Tensor => 
Tensor Float [2,2] [[ 0.0000,  2.0000   ],
                    [ 3.0000   ,  5.0000   ]]

Writing through the same lens with .~ returns an updated tensor, with everything outside the slice untouched:

t & [lslice|0|] .~ zeros' [3] => 
Tensor Float [2,3] [[ 0.0000,  0.0000,  0.0000],
                    [ 3.0000   ,  4.0000   ,  5.0000   ]]

And %~ modifies the focused slice with a function:

t & [lslice|:, 1|] %~ ((* 100) :: Tensor -> Tensor) => 
Tensor Float [2,3] [[ 0.0000,  100.0000   ,  2.0000   ],
                    [ 3.0000   ,  400.0000   ,  5.0000   ]]

This is the lens counterpart of t[0] = ... in Python — but pure: the original t is unchanged.

Structure traversals (untyped)

Torch.Lens provides types, a generic traversal of every value of a given type inside a structure. Any record with a Generic instance works.

data TwoLayer = TwoLayer
  { weight1 :: Tensor
  , weight2 :: Tensor
  , steps   :: Int
  } deriving (Generic, Show)
let m = TwoLayer (ones' [2, 3]) (ones' [3]) 0

Collect every tensor:

map shape (flattenValues (types @Tensor) m) => 
[[2,3],[3]]

Rewrite every tensor — this is exactly how toType and toDevice convert whole models; here we switch the model to half precision:

map dtype (flattenValues (types @Tensor) (toType Half m)) => 
[Half,Half]

Note the Int field rides along untouched; only the tensors are visited.

Field and shape lenses (typed)

In the typed API, dimensions can be records, and record fields become lenses. field @"r" reads or writes the r component of an RGB dimension, and its very existence is checked at compile time — field @"q" would not compile.

data RGB a = RGB { r :: a, g :: a, b :: a } deriving (Generic, Show)
let img = T.fromUnnamed T.ones :: T.NamedTensor '( 'CPU, 0) 'Float '[Vector 2, RGB]
T.toDynamic (img ^. TL.field @"r") => 
Tensor Float [2] [ 1.0000   ,  1.0000   ]
T.toDynamic (img & TL.field @"g" .~ T.fromUnnamed T.zeros) => 
Tensor Float [2,3] [[ 1.0000   ,  0.0000,  1.0000   ],
                    [ 1.0000   ,  0.0000,  1.0000   ]]

Slicing lenses exist here too: sliceLens is the typed lslice, built from getSlice and setSlice, and the same slice quasiquoter works in type position. The focused shape is computed at compile time, so writing a wrongly-shaped value through the lens does not compile.

let tt = T.ones :: T.Tensor '( 'CPU, 0) 'Float '[2, 3]
T.toDynamic (tt ^. TI.sliceLens @[slice| 1 |]) => 
Tensor Float [3] [ 1.0000   ,  1.0000   ,  1.0000   ]
T.toDynamic (tt & TI.sliceLens @[slice| :, 1 |] %~ (* 100)) => 
Tensor Float [2,3] [[ 1.0000   ,  100.0000   ,  1.0000   ],
                    [ 1.0000   ,  100.0000   ,  1.0000   ]]

The outward traversal also exists in typed form, where it becomes shape-selective: types at a typed tensor target visits only the tensors of exactly that device, dtype and shape.

data TypedNet device = TypedNet
  { l1 :: T.Tensor device 'Float '[2, 3]
  , l2 :: T.Tensor device 'Float '[3, 4]
  , l3 :: T.Tensor device 'Float '[2, 3]
  } deriving Generic
let net = TypedNet T.ones T.ones T.zeros :: TypedNet '( 'CPU, 0)

Only the two '[2, 3] layers are visited; the '[3, 4] one is not:

length (flattenValues (types @(T.Tensor '( 'CPU, 0) 'Float '[2, 3])) net) => 
2
T.toDynamic (l3 (over (types @(T.Tensor '( 'CPU, 0) 'Float '[2, 3])) (+ 1) net)) => 
Tensor Float [2,3] [[ 1.0000   ,  1.0000   ,  1.0000   ],
                    [ 1.0000   ,  1.0000   ,  1.0000   ]]

replaceValues swaps specific structures wholesale — replace the two '[2, 3] tensors and leave everything else:

T.toDynamic (l1 (replaceValues (types @(T.Tensor '( 'CPU, 0) 'Float '[2, 3])) net [T.zeros, T.ones])) => 
Tensor Float [2,3] [[ 0.0000,  0.0000,  0.0000],
                    [ 0.0000,  0.0000,  0.0000]]

Retyping conversions (typed)

Finally, the typed counterparts of toType/toDevice change the type along with the values. Torch.Typed.DType.toDType converts every tensor in a model and rewrites the model's dtype parameter, so after converting to 'Half a stray 'Float batch is a compile-time error rather than a silent upcast:

model                                :: Linear 10 1 'Float '( 'CPU, 0)
toDType @'Half @'Float model         :: Linear 10 1 'Half  '( 'CPU, 0)
toDevice @'( 'CUDA, 0) @'( 'CPU, 0) model
                                     :: Linear 10 1 'Float '( 'CUDA, 0)

See Named Tensors and Lenses for the reference treatment of these conversions.