
Structure and Interpretation
of Tensor Programs
First Edition
A Whirlwind Tour to Deep Learning Alchemy and Deep Learning Systems
Run 🤖nanochatby building 
borschtfrom scratch: a hackable subset of 🔥torchWhat’s in a name? Borscht is a sour soup with meat from Eastern Europe, most often associated with the variant originating in Ukraine with red beetroots
. It’s a permutation-like pun on torch, given how the deep learning framework borscht aims to be a rough subset of torch. You want a torch.Tensor? You get the 
borscht.Tensor! Also, my mother in law’s borscht is to die for: Мне очень нравится ваш борщ, миссис Ким)
A new breadth of mathematical computer science for the world of artificial intelligence
Made with 🖤🪻 by Jeffrey Zhang, University of Waterloo (BMath)
Made possible by Lambda Labs Research Grant
You are viewing this on a mobile device, but SITP is best viewed on a desktop — the book includes various multimedia lecture videos, visualizers, any tufte-style sidenotes with many external hyperlinks to other resources.
Citation:
@book{zhang2026sitp,
author = "Jeffrey David Zhang",
title = "Structure and Interpretation of Tensor Programs",
year = 2027,
url = "https://sitp.ai"
}
Dedication

In loving memory of my father, my teacher, and my best friend Dr. Thomas Zhang ND., R.TCMP, R.Ac.
The love I put into this book and course is but a fraction of the love he gave me.
May you rest in pure land. I am happy that it was in our karma to meet and learn during this lifetime.
三月七日,沙湖道中遇雨。雨具先去,同行皆狼狈,余独不觉。已而遂晴,故作此词。
莫听穿林打叶声,
何妨吟啸且徐行。
竹杖芒鞋轻胜马,
谁怕?
一蓑烟雨任平生。
料峭春风吹酒醒,
微冷,
山头斜照却相迎。
回首向来萧瑟处,
归去,
也无风雨也无晴。
On the seventh of the third month, we were caught in the rain along the Shahu Way without any rain gear. Everyone felt awkward, except myself. The sky later cleared, and I wrote these lyrics:
Heed not the rain beating against the leaves
Why not whistle and sing as you stroll along
Straw sandals and a bamboo staff
are lighter and better than horseback
What’s there to fear?
A raincoat is all I need in life’s journey
A cool spring breeze sobers up the spirit,
a slight chill,
Yet the setting sun over the mountains is welcoming.
Looking back over this bleak path
Were I to return
I would heed not rain
nor shine.
《定风波·莫听穿林打叶声》(元丰五年) 苏东坡 大山及友人 英译
“Calming the Waves” (1082) by Su Dongpo, tr. Dashan & Friends
Course Thesis
Presenting an early outline of SITP at Toronto School of Foundation Modeling Season 1 (2025)
The Structure and Interpretation of The AI Curriculum
This book is aspirationally titled The Structure and Interpretation of Tensor Programs, (SITP) as it’s goal is to serve a similar role for software 2.0 as The Structure and Interpretation of Computer Programs (SICP) did for software 1.0. Written by Harold Abelson and Gerald Sussman with Julie Sussman, SICP took learners on a whimsical whirlwind tour throughout the essence of computation starting with the elements of programs with functional programming, higher order functions, data abstraction, streams, and ending with programming their own programming languages with virtual interpreters and compilers to register machines. Although perhaps somewhat more inconspicuous, there also exists The Structure and Interpretation of Classical Mechanics (SICM) (Sussman and Wisdom 2001) and Functional Differential Geometry (FDG) (Sussman and Wisdom 2013) which cover classical and quantum mechanics of physics respectively. We’d like to imagine if Sussman and other Schemers had more energy, the next text would perhaps not cover thermodynamics, but rather deep learning. We thus present The Structure and Interpretation of Tensor Programs (SITP).
My alma matter was amongst those which took the SICP approach, and as intended, for someone coming into first year college have taken some elementary computer science courses from high school, it blew my mind (Lambda Calculus!). After graduating college in 2022, I followed my curiosity for diving deeper into the souls of our machine by going on to developing industrial languages and runtimes.“There is only one project, architecture, operating system and languages, compiler, it’s only one project. It’s all together.” – Boris Babayan. Particularly, I hacked on languages with domain specific cloud compilers and runtimes with cloud provisioners, and cloud garbage collectors. At the end of 2022 though, when ChatGPT was released by OpenAI my mind was blown twice more. As someone programming for some time, I could not believe this at all. After two more years of hacking on cloud languages and runtimes, I started my transition from domain specific cloud compilers from GPS to TERRAFORM to domain specific tensor compilers from TORCH to TRITON.It turns out there is effectively zero overlap except that they are both compilers in that they take some source program and produce some target program.
The transition started with a tweet showcasing the beginnings of a tensor library evaluating the forward pass of a feed forward network
from Andrej Karpathy’s Neural Networks: Zero to Hero course.
While it was illuminating to start implementing each individual torch call that the nets from makemore were making,
my knowledge felt quite fragmented as I personally forgot lots of foundation from collegeI had the definition of a gradient and basis in the basement of my subconscious at best. Thank you Professor.West for sharing your juggling, and Professor. Wolczuk for sharing your Ukrainian floor dancing,
and I wasn’t sure how to bridge myself to industrial deep learning systems like tinygradHere’s a PR I was able to land in tinygrad making logsumexp numerically stable. It felt like I was contributing to LLVM without having taken a course on compiler construction to learn the basics of parsing, optimization, and lowering., torch, jax, vllm, and sglang.
Shortly after, I decided to take the plunge and started drinking from the firehose of deep learning canon: Hastie et al., Murphy, Goodfellow et al., you name it. The one thought I could not get out of my head was where is the SICP for software 2.0? While I found two excellent resources on building your own torch-like autograd by Tianqi Chen’s 10-414 Carnegie Mellon and Alexander Rush’s CS5781 at Cornell, I personally would have enjoyed a more unified resource that took me from math, to deep learning, to deep learning systems in a single unbroken sequence of thought, and perhaps others would feel similarly. That is the genesis story for this book, whose central research question is the following: What does the SICP for deep learning look like?
Pur shortly, SITP, is a function that maps learners from the product type of an introductory computer science book such as DCIC (Fisler et al. 2025) and an introductory systems programming book such as TRPL (Klabnik et al. 2026) with experimental modifications (Crichton et al. 2023) to a frontier model report such as the frontier technical report of The Llama 3 Herd of Models (Meta 2024) and frontier systems book such as Hugging Face’s Ultra Scale Book (Tazi et al. 2025).
where the function is the systematic and principled design of a curriculum implemented by chimera-like professors and lecturers whom are usually interested and capable in the intersection of both programming language theory (PLT) and computer science education (CSE). In the same way that we use non-intersective adjectives in the distinctions of fake gun vs gun, non-tight language model vs language model, there is also the teaching professor vs professor“I think of the position as having “twice” the teaching and “half” the research as an assistant professor (of course individual approaches can and do differ!).” – @JoePolitz, September 5, 2024. That is, SITP applies the form of HTDP developed by such teaching professors to the substance of deep learning. What then, is such a form?
The How to Design Programs HTDP text This was the actual text used at my alma matter. Thank you to Professor Ragde for bringing it over, and to Professor Vasiga for bringing it to life. was also seminal in that it was the first computer science text to treat curriculum design as scientific research and an engineering problem (Felleisen et al. 2004), building off the shoulders of the giant that SICP (Abelson and Sussman 1996) is.
First, the book discusses explicitly how programs should be constructed. Second, to tame the complexity of programming, it defines a series of teaching languages based on Scheme that represent five distinct knowledge levels through which students pass during their first course. The levels correspond to the complexity of data definitions that the program design guidelines use. Third, the book uses exercises to reinforce the explicit guidelines on program design; few, if any, exercises are designed for the sake of domain knowledge. Finally, the book uses more accessible forms of domain knowledge than SICP. Because of this shift in emphasis, we gave our book the title How to Design Programs (HTDP).
the concreteness fading (Fyfe et al. 2014) of declarative concepts in mathematics (Hestenes et al. 1992) by presenting notions just in time by defining them by their context, and iteratively refining (progressive disclosure) such concepts from the informal to formal ending in HtDP-style IntermezzosWhich present the formal syntax and semantics of a language..
that transfer (Bransford and Schwartz 1999) to procedural skills of computation (Price et al. 2021) in the context of deep learning. Such transfer is possible because the computational discipline of deep learning has lots of structural similarity with mathematics, and is why we have books such as Deisenroth et al. (2020), and subsequent courses follwing such book.
When it comes to the discipline of deep learning, there are two large jumps of transfer a learner must make. The first one is that a learner must learn the mathematics for machine learning, while although elementary, has a non-trivial breadth of probability theory, linear algebra, differential calculus, and if the learner has the time, to deepen such concepts with information theory, measure theory, and analysis. One of the primary texts that seeks to provide a unified treatment in such mathematics for machine learning is (Deisenroth et al. 2020)“For instance, Princeton’s COS302 offered by Ryan Adams, which turns out to be the lab that created created HIPS/autograd (led by Matthew Johnson, Dougal Maclaurin, and David Duvenaud), which inspired PyTorch! See https://soumith.ch/blog/2023-12-17-pytorch-design-origins.md.html#/origins., which provides an excellent two part structure with the foundational mathematics, followed by the applied. The core motivation is that any gaps in mathematics can be filled, preparing a learner for a text like such (Goodfellow et al. 2016) to deepen their understanding in neural network architecture.
More recently, with the advent of scaling large language models (Kaplan et al. 2020) and the slowing of Moore’s Law (Moore 1965), another jump of transfer the learner must make is how to efficiently map their architecture and optimizers to massively parallel hardware at the level of a single graphics processing unit (GPU) and multi GPU. The two canonical texts for this are (Hwu et al. 2022) for systems, and more recently (Tazi et al. 2025)“Alternatively, JAX Scaling Book (link.).
As someone opening up PMPP, it was extremely frustrating to read the appendix that briefly covered the process of deep learning without a proper foundation in the modeling of architecture and optimizers.
When I realized the two large jumps I was making from re-learning foundational mathematics to frontier architecture, and from modeling frontier architecture to accelerating frontier architecture, my “aha” moment was realizing the vast bridges a learner was being asked to cross, and realizing Shriram’s taste of finding some intermediary point to smoothen such a progression. This was the motivation for the explicit Design Recipe in HtDP to go from blank page to a well-designed program, and more recently, property-based testing to go from unit tests to model checking and SAT solvers. To return to the short description, SITP that maps learners from the product type of an introductory computer science book and an introductory systems programming book to a frontier model report such as the frontier technical report and frontier systems book. That is,
unity/intertwined mathematics and programming
- Evaluation (Benchmarks): Stochastics of probability theory
- Specification (Architecture): Dimensionality of linear algebra
- Implementation (Optimizers): Approximation of differential calculus , , ,
- Computation (Systems): Rooflines of processors
For instance, the traditional ordering one might approach to the discipline of deep learning is to synthetically deduce definitions starting from what is considered to be elementary (i.e your choice of foundations which you pay lip service to as a working mathematician: ZFC or DTT). The benefits of this approach is that the transfer (Bransford and Schwartz 1999) of concepts is maximized — afterall, studying mathematics is simply generic programming. While this is no doubt the standard for most mathematical texts, this is what was special about the textbook How to Design Programs, an explicitly curriculum designed and engineered for transfer (Felleisen et al. 2004) expositing programming based off the principles of set theory (whatever that may mean).
all in the context of autoregressive language modeling, culminating in the transformers architecture
Jeffrey Zhang
Waterloo, Ontario
August 2026
Course Design Thesis
“Civilization advances by extending the number of important operations which we can perform without thinking of them.” Thanks, Alfred North Whitehead (1861-1947)
Management: Hey Alyssa P. Hacker, we suspect ClosedAI might be snooping on our research ideas we discuss with their models, namely the one high research project on building the first dyson sphere in order to reach the Andromeda Galaxy is top secret. We want to be in control of our own destiny, so we need you to train a good LLM, so here’s 10k B200 GPUs for the month in order to prototype for a larger hero run. Moreover, as you know, we are hedging our Andromeda research efforts with the robotics arm of the company, and customers have been wanting the models to have a personality that grows like that one sci-fi film “Him” — so we need you to innovate on the architecture to get us every so closer towards such a vision, rather than simply copying ShallowPeak. Unfortunately, if the $100M training run diverges without any particularly useful learnings, we will have to fire you. Our board and shareholders need someone to be responsible.
Alyssa P. Hacker
Designing a neural network with an evolving personality.
Weighing growth mechanisms like slow traits versus expanding structure.
Checking whether project memory warrants a lookup first.
Designing trait vectors that modulate layers and expand over time.
Weighing PyTorch installation against a lightweight autograd alternative.
Considering JAX as a lighter-weight install than PyTorch.
Drafting FiLM-based layers that fold in growing trait vectors.
Tracing how new trait dimensions could gain real meaning.
Settling on three timescales: weights, traits, and moment-to-moment activity.
Designing a growth step that preserves existing network behavior.
Weighing a meta-learned hypernetwork to seed new trait dimensions.
Designing a simulated environment where personality mirrors a hidden niche.
Specifying surprise-driven growth and metrics for trait consistency.
Deciding whether to run the demo in PyTorch or JAX.
Searching for primary sources to ground design citations.
Drafting the synthetic world that drives trait growth.
Designing gated trait updates with surprise-aware stability biases.
Calibrating when accumulated surprise should trigger growth.
Settling on a fixed surprise threshold for triggering growth.
Untangling how random trait ordering could break symbol grounding.
Choosing a fixed onset order to keep trait meanings stable.
Defining metrics for stability, consistency, individuation, and experience.
Coding the synthetic world that generates niche-driven data.
Tuning signal strength so trait vectors stay informative.
Coding the behavior and integrator network modules.
Preventing runaway growth cascades after a trait dimension expands.
Writing the main training loop and evaluation metrics.
Adding developmental clocks so traits mature and resist shocks.
Testing twin lives and planning plots for personality trajectories.
Identifying research on persona vectors and personality traits.
Designing a JAX implementation of a growing trait vector network.
Coding the world simulator and model parameter setup.
Building the integration and gating logic for trait updates.
Wiring growth bookkeeping and lifetime simulation loop.
Estimating training runtime and outlining evaluation metrics.
Defining probe, shock, and PCA evaluation experiments.
Choosing hyperparameters and settings for the simulation code.
Benchmarking training speed across warmup and later steps.
Diagnosing why loss stays above the noise floor.
Evaluating whether the update rule can track trait signals.
Expanding network capacity to sharpen trait-tracking accuracy.
Weighing absolute versus relative thresholds for triggering growth.
Hitting a tool limit while trying to finalize file output.
Preparing a results summary without the generated files.
Condensing the full implementation into a compact inline version.
Writing out world-state sampling and parameter initialization routines.
Coding the behavior network and lifelong parameter update rules.
Assembling the training loop and meta-training driver.
Management: Hey Eva Lu Ator, we just tasked Alyssa P. Hacker to train our own in-house model. Our research in silicon photonics has allowed us to build a faster alternative to MVIDIA’s GPUs, with machine speeds reaching YottaFLOP/S. We need you to implement our own deep learning framework and kernels that can support our chips and MVIDIA’s as well in case we our fabrication yields don’t hit targets. Also, if Alyssa P. Hacker’s decides to explore new architectures or optimizers, we need you to implement the kernels. Notwithstanding hardware failures, if anything goes wrong with the software infrastructure during the $100M training run, we will unfortunately have to fire you. Our board and shareholders need someone to be responsible.
Eva Lu Ator: “implement pytorch”
> 7:22AM Let me first look at what's in the working directory to scope this.
> Thought for 432s
> 7:30AM The directory is empty and lacks numpy, torch, and pytest,
so I'll build "nanotorch" — a NumPy-based PyTorch clone with a
familiar API (Tensor, autograd, nn, optim, data loading, saving/loading),
testing against real PyTorch where possible. Starting with environment setup.
> Bash Check CPU count, memory, and Python package tooling
> Machine: 8-core M3 with 8GB RAM, so a workflow can run about 6 agents at once.
Next I'll create a virtualenv with numpy and pytest, plus real PyTorch to use as the reference in tests.
> Bash Create virtualenv and install numpy and pytest
> Bash Install real PyTorch as a test oracle
> Thought for 2s
> Real PyTorch 2.14.1 is installed as the oracle.
Before writing the design spec, I'll check a few numpy behaviours the core will depend on.
> Bash Verify numpy reshape/promotion features and torch promotion
> Thought for 607s
> Environment's set with numpy 2.5.4, pytest 9.1.1, and PyTorch 2.14.1 as the oracle.
Now I'll verify the PyTorch behaviours against the real library before drafting the design contract.
> Bash Probe PyTorch semantics for the design spec
> These results match the spec I had planned. Next I'll write the design contract (docs/DESIGN.md)
and pyproject.toml, and make the package importable from the venv.
> Write pyproject.toml
> Bash Make repo importable in venv and confirm torch sources exist
> 7:40AM Now the design contract. It's long on purpose: it pins down the core semantics
and file ownership so parallel agents produce one coherent library.
> Write DESIGN.md
> Thought for 233s
> With the design contract done, I'll now build the core foundation
first—Tensor, dtypes/type promotion, autograd, views/in-place ops, indexing,
creation, random, test helpers, and the package skeleton—then subject
it to five red-team reviews against real PyTorch with fixer rounds until tests pass,
since everything else depends on this layer.
> Thought for 141s
> 7:50AM Workflow 1 is running in the background: one core implementer,
then 5 red-team reviewers in parallel, then up to 3 fixer rounds.
The core is the largest single piece, so this will take a while.
I'll be notified when it finishes, then review the core myself
before launching the 9 parallel op/nn families of Workflow 2.
“Everything is vague to a degree you do not realize till you have tried to make it precise.” Thanks, Bertrand Russel (1872-1970)
DESIGN.md
# nanotorch — design and contributor contract
nanotorch is a from-scratch reimplementation of the PyTorch Python API on top of NumPy.
Code written for PyTorch should run unchanged with `import nanotorch as torch` for the
supported subset and produce the same results (up to floating-point rounding and RNG streams).
* **We implement:** tensor semantics (dtypes and type promotion, broadcasting, views,
in-place ops, indexing), the autograd engine (dynamic graph, higher-order gradients,
hooks, version counters), every derivative formula, `nn`, `optim`, `utils.data`,
serialization, printing.
* **NumPy provides:** strided n-d array storage and vectorised kernels (ufuncs, BLAS matmul).
This document is the contract every contributor (human or agent) follows. Section 5 (op
authoring guide) is finalised by the core implementation and must stay in sync with the code.
## 0. Ground rules
1. **Fidelity target: PyTorch 2.x.** The oracle is the real `torch` 2.14.1 installed in
`.venv`. When in doubt about a behaviour, run torch and match it: names, signatures
(positional order, keyword names, defaults), output shapes, output dtypes, exception
*types* (message text should be close but need not be identical).
torch's own Python sources (`.venv/lib/python3.14/site-packages/torch/...`, e.g.
`nn/modules/module.py`, `optim/adam.py`, `_tensor_str.py`, `nn/init.py`) are a legitimate
reference for semantics. Write clean, minimal implementations; when you substantially
adapt PyTorch code, add a `# Adapted from PyTorch (BSD-3-Clause)` comment.
2. **nanotorch never imports torch.** Only tests import torch (through `pytest.importorskip`).
3. **NumPy (>= 2.1) is the only runtime dependency.** No scipy. Pure-Python fallbacks are
fine for special functions (implement accurate vectorised approximations).
4. Python >= 3.10 syntax (we run 3.14). Type hints welcome, not required.
5. **Non-goals** (do not implement, do not test): CUDA/MPS execution (CPU only; `device`
objects for other types can be constructed but allocating on them raises), complex
dtypes, bfloat16 (placeholders that raise a clear error on use), sparse / quantized /
named / nested tensors, `torch.compile` / `jit` / `fx` (no-op shims only), distributed,
multi-process DataLoader workers (run in-process), ONNX, `vmap`, FFT, exact RNG-stream
parity with PyTorch (same *distributions*, different numbers).
6. Repr strings mirror PyTorch exactly where reasonable, including the `torch.` prefix:
`torch.float32`, `torch.Size([2, 3])`, `device(type='cpu')`,
`tensor([1., 2.], requires_grad=True)`, `<MulBackward0 object at 0x...>`.
(i.e Why another deep learning book?)
Learning Outcomes (Tasks) learning probes, observable, signals, channels for feedback interactive learning -> no longer producing code (hieroglyphics)
concept inventory (Hestenes et al. 1992)
Software 2.0, following (Krishnamurthi and Fisler 2020)
- Lectures
- Readings (with concreteness fading following (Fyfe et al. 2014)) (with interactive quizzes following (Crichton and Krishnamurthi 2024)) with SITP
- Declarative concepts
- property based testing
- tradeoff between x and y
- system design/system invariants
- Declarative concepts
- Labs (with Agentic Coding following (Fisler et al. 2026))
- Procedural skills
- write a good design doc/test suite
- code review
- Procedural skills
- centered around the Teaching Array Programming Language

borscht.Tensorwith language levels following the form of (Felleisen et al. 2004) but with the substance of NETSOR and NETSOR$\top$ from (Yang 2019) and (Yang 2020) as well as PTX, CUDA, CuTeDSL, Cutile.
Future Directions
- integrate a trimmed version of such curricula with highschool mathematics, following (Felleisen and Krishnamurthi 2009) and Guzdial
- collect profile data to suggest pedagogic interventions to imporove learner outcomes
- actually encapsulate language levels like racket-like
#langwith julia metaprogramming - map out the semantic space of language features with a Standard Model of APLs following (Krishnamurthi 2008)
Syllabus
Any sufficiently advanced technology is indistinguishable from magic. Thanks, Arthur C. Clarke (1917-2008)
Welcome to SITP, the online course for a self-guided study which provides an accelerated introduction to deep learning networks and deep learning systems.
All text (every single word) has been written by a human, for a human, as this book is not a reference book, but rather a cognitive helmet of understanding for a human. You are highly encouraged if not expected to read with an LLM, but be aware (that is, to beware) of how much you cognitively offload and outsource your understanding.
- Week 1. Hello Tensor 👋
- Week 2. I Perceive You 👀
- Week 3. MetaCircularEvaLuAtor! ↩️
- Week 4. GPUs Go Brrr 🏎️
- Week 5. King - Man + Queen = 👩
- Week 6. Plato’s Cave 𐦂𖨆𐀪𖠋
- Week 7. AutoNabla (≧∇≦)
- Week 8. Don’t Think 🤔
- Week 9. Just Do 🏃♂️
- Week 10. Compilers, They’re InTense 😰
Week 1. Hello Tensor 👋
- Watch ▶️ Lecture 1
- Read 📖 Chapter 1.1 From Certain to Uncertain Knowledge,
- Read 📖 Chapter 1.2 Next Token Prediction is Classification and Compression
- Write Lab 1. Elements of Tensors
Optional Reading
- Read An Example of Statistical Investigation of the Text Eugene Onegin Concerning the Connection of Samples in Chains (Markov 1913)
- Read A Mathematical Theory of Communication (Shannon 1948)
- Read A Proposal for the Dartmouth Summer Research Project on Artificial Intelligence (McCarthy et al. 1955)
Week 2. I Perceive You 👀
- Watch ▶️ Lecture 2
- Read 📖 Chapter 1.3 Parameterizing Classification with Discriminant Functions
- Read 📖 Chapter 1.4 Parameterizing Classification with Probabilistic Models
- Write Lab 2.
Optional Reading
- Read The Perceptron: A Probabilistic Model for Information Storage and Organization in the Brain (Rosenblatt 1958)
- Read Statistical Modeling: The Two Cultures (Breiman 2001)
- Read The Epic Story of Maximum Likelihood (Stigler 2008)
Week 3. MetaCircularEvaLuAtor! ↩️
- Watch ▶️ Lecture 3
- Read 📖 Chapter 2.1 Virtualizing Shapes with Strides
- Read 📖 Chapter 2.2 Accelerating Basic Linear Algebra on CPUs
- Read 📖 Chapter 2.3 From Performance Primitives to Linear Solvers
- Write ✍️ Lab 3. Tensor++ and Matrix Decompositions
Optional Reading
- Read A Set of Level 3 Basic Linear Algebra Subprograms (Dongarra et al. 1990)
- Read Anatomy of High-Performance Matrix Multiplication (Goto and van de Geijn 2008a)
- Read The History of Fortran I, II, and III (Backus 1978)
- Read A History of MATLAB (Moler and Little 2020)
- Read Array Programming with NumPy (Harris et al. 2020)
Week 4. GPUs Go Brrr 🏎️
- Watch ▶️ Lecture 4
- Read 📖 Chapter 2.4 Accelerating Matrix Multiplication on GPUs
- Write ✍️ Lab 4. Accelerating Matmul with Tiles and Layouts
Week 5. King - Man + Queen = 👸
- Watch ▶️ Lecture 5
- Read 📖 Chapter 3.1 Learning Static Representations with FFNs
- Read 📖 Chapter 3.2 Iterative Optimization
- Write Lab 5. Embedding Projector
Week 6. Plato’s Cave 𐦂𖨆𐀪𖠋
- Watch ▶️ Lecture 6
- Read 📖 Chapter 3.3 Learning Representations with CNNs
- Read 📖 Chapter 3.4 Learning Representations with RNNs
- Read 📖 Chapter 3.5 Learning Representations with GPTs
- Write ✍️ Lab 6. Vision Language Action Model
Week 7. AutoNabla (≧∇≦)
- Watch ▶️ Lecture 7
- Read 📖 Chapter 4.1 Automatic Differentiation
- Read 📖 Chapter 4.2 Gradient Based Optimization
- Write ✍️ Lab 7. Custom Autograd Backwards for VLA
Week 8. Don’t Think 🤔
Week 9. Just Do 🏃♂️
Week 10. Compilers, They’re InTense 😰
Frontispiece to Galileo’s Dialogue Concerning the Two Chief World Systems (Stefano della Bella 1632)
You are viewing this on a mobile device, but SITP is best viewed on a desktop — the book includes various multimedia lecture videos, visualizers, any tufte-style sidenotes with many external hyperlinks to other resources.
Philosophy is written in that great book which ever lies before our eyes—I mean the Universe—but we cannot understand it if we do not learn the language and grasp the symbols in which it is written. This book is written in the mathematical language, and the symbols are triangles, circles, and other geometrical figures without whose help it is impossible to comprehend a single word of it, without which one wanders in vain through a dark labyrinth.
Galileo Galilei
I. Elements of Networks
Although separated by over 2000 years, programmers of Silicon Valley face a daunting task from the cosmos quite similar to the one encountered by the mathematicians of Ancient Greece. That is, during this critical moment for action otherwise known to the Greeks as kairos (καιρός), they must climb back down from their current pitch and backtrack to the beginner’s mind they once had in order to contribute towards this new approach of augmenting and amplifying human intelligence that is currently underway.
No different from learning another mathematical or programming language, they must transition from their finitely discrete structures and deterministic procedures they have grown acustomed to and make the transition to the infintely continuous structures and stochastic procedures. Back then, ancient greek mathematicians were only comfortable with the finiteness of natural numbers like , , and , and had to grapple with the infinite nature of the real numbers such as , , and . Similarly, the programmers of today are being asked to transition from programming algorithms of sets, maps, lists, trees, and graphs on serial machines to optimizing the distributions of scalars, vectors, matrices, tensors, and neural networks on massively parallel machines. More coloquially, programmers interested in the deep learning approach to artificial intelligence must make the transition from software 1.0software 1.0 to software 2.0software 2.0 See (Karpathy 2017), a distinction used to differentiate the classical act of programming software line by line, and the newer approach of programming software by feeding a dataset to a neural net architecture that has a goal, and searching the space of programs with compute. How to exactly program with this approach will take the remainder of this book to explain.
While software 2.0 has increased the intelligence and autonomy of our devices throughout the past decade
— to name a few, language understanding with Google Translate and Apple Siri, vision understanding with Tesla Autopilot —
at the end of 2022 ChatGPT was released to the world marking the beginning
of software 3.0software 3.0
See (Karpathy 2025),
enabling the activity of programming with none other than the English language.
What may be surprising to realize is that artificial intelligence like ChatGPT is “just” another computer program.
However, rather than being implemented in a language like C, JAVA, or JAVASCRIPT, it’s implemented in one that goes by the name of PyTorch,
a software 2.0 programming language centered around torch.Tensor, a multidimensional array humbly embedded within a Python package.
WeA modified excerpt from The Structure and Interpretation of Computer Programs §1: Building Abstractions with Procedures are about to study the idea of a computational process. Computational processes are abstract beings that inhabit computers. As they evolve, processes manipulate other abstract things called data. The evolution of a process is directed by a pattern of
rules called a programparameters called a model. Peoplecreate programstrain models to direct processes. In effect, we conjure the spirits of the computer with our spells.
A computational process is indeed much like a sorcerer’s idea of a spirit. It cannot be seen or touched. It is not composed of matter at all. However, it is very real. It can perform intellectual work. It can answer questions. It can affect the world by disbursing money at a bank or by controlling a robot arm in a factory. Theprogramsmodels we use to conjure processes are like a sorcerer’s spells. They are carefullycomposedrecovered fromsymbolicnumerical expressions in arcane andesotericparallel programming languages that prescribe thetaskslosses we want our processes toperformminimize.
So in this whimsical whirlwind tour dubbed The Structure and Interpretation of Tensor Programs (SITP), we will embark on a quest to build from scratch
our own deep neural network like ChatGPT by implementing nanochat
and our own deep learning framework like torch by implementing 
borscht.
Whether you’re an eager high school student, a rising college student, or a battle-tested industrial programmer,
SITP has been meticulously designed so that the only prerequisite required
is a basic familiarity with the elements of programming, and high school algebra.
Any additional experience is helpful, not mandatory.
If you’d like to revisit your programming fundamentals, we recommend A Data-Centric Introduction to Computing (Fisler et al. 2025) (DCIC) which is a great introduction to computing with a focus on data scienceSee (Krishnamurthi and Fisler 2020) that starts with the Pyret teaching language and the table data structure (the torch.Tensor in disguise!), and smoothly graduates to Python,
the starting point of our adventure herein.
Down the rabbit hole we go.
I. Table of Contents
- 1. Self-Supervised Sequence Learning with Single-Layer Networks
- Intermezzo One: The Language of Dimension and Stochasticity
- 2. The Massively Parallel Multidimensional Tensor

La perspective curieuse (Jean-François Niceron 1638)
1. Self-Supervised Sequence Learning with Single-Layer Networks
In which we transition from the deterministic and discretely finite software 1.0 to the stochastic and infinitely continuous software 2.0 by rediscovering ngrams by Markov and Shannon as well as perceptrons by Rosenblatt using the languages of probability theory and linear algebra afforded by the
borscht.Tensor. At the end, we will have rediscovered the early period where researchers in artificial intelligence were uncertain between methods of connectionism and logical symbolism.
1.1 From Certain to Uncertain Knowledge
In which we have our first encounter with the language modeling of GPT-2 via
borscht; the primary notions from probability and linear algebra thus used in modeling next-token prediction, namely random variables, their distributions, and their computational equivalent of the multidimensional array.
1.1.1 From Software 1.0 to Software 2.0
Table of Contents (§1.1 From Certain to Uncertain Knowledge)
The gifts that the information revolution brought forth to humanity, at their essence, have simple explanations. As a first approximation, the digital computer can be described as 0s and 1s, the intergalactic computer network as an information highway, and the cloud as computers in the sky. The same can be said for those that the intelligence revolution is currently bringing in. Assistants can be described as LLMs trained with thumbs up or thumbs down, reasoners as models that produce long chains of thought before answering, and agents as models which have access to tools. This magic is continuing to grow as people are even composing agents together into swarms, but the key technology that underlies everything is the large language model, which itself can be simply explained as a next-token predictor. That is, given some user prompt as input, it generates an answer by repeatedly producing a probability distribution over the next word, sampling a word, and appending such word to the input.
Although ChatGPT, Claude and friends are relatively new to our cosmos, the idea of generating sentences with next token prediction is surprisingly not new and dates back to the work of Russian mathematician Andrey Andreyevich Markov in 1913, and shortly after Claude Shannon in 1948. So why is humanity’s so-called tech tree late to such technology? Predominantly for two reasons.
Philosophically, because of the eternal tension between the discretediscrete and continuouscontinuous methods in describing our reality. Programmers were reluctant to use stochastic and infinitely continuous techniques, favoring those that were logical and finitely discrete. However, … like a physicist position of every particle in a vacuum, even with a set of initial equations for position and momentum with equations for change, describing reality with too many parts to countbitter lesson. This created the need for statistical mechanics, describing particles with probability distributionsprobability distributions.
Practically, it’s because of the fossil-fuel like subsidy of datadata provided by the aforementioned intergalactic computer network we call the web, the computecompute provided by massively parallel processors originally designed for video games we call graphic processing units (henceforth GPUs), which can be used efficiently by a neural network architecturearchitecture called transformer. This is why ChatGPT and Claude are referred to as large language modelslarge language model.
Large Language Models explained briefly (3Blue1Brown 2024)
Large language models are trained using methods from the discipline of deep learningdeep learning, which in turn, are based in the statistical machine learningmachine learning approach to artificial intelligence. This means, in order to produce such a probability distribution over possible next words, ChatGPT, Claude and others use mathematical machinery from the areas of probability theory (clearly), linear algebra, and calculus.
That is, the testingtesting resulting from the evaluation of next-token prediction is stated using the stochasticitystochasticity of probability theory; the specificationspecification of an architecture’s parameterized inductive bias is stated using the dimensionalitydimensionality of linear algebra rather than the discreteness of combinatorics; the implementationimplementation of an architecture’s realized parameters is iteratively optimizedoptimized using the approximation of differential calculus rather than the control flow of structured programming; and the computationcomputation that carries out such an optimization is speed of light acceleration using the rooflines of processor pipelines and memory hierarchies rather than the more abstract cost model of worst case complexity. Put shortly, software 2.0 is built by programming continuous mathematics on highly parallelized and distributed machines — how to exactly program with this new approach will take the remainder of the book to iteratively deepen our understanding of evalsevals, architecturearchitecture, optimizersoptimizers, and systemssystems throughout SITP.
| 1.0 goal | 1.0 method | 2.0 goal | 2.0 method |
|---|---|---|---|
| testing | deterministic unit tests | evaluation | next-token prediction with stochastics of probability theory and compression of information theory |
| specification | maps and graphs with discreteness of combinatorics | architecture | parameterized inductive biases with dimensionality of linear algebra |
| implementation | conditionals, functions with control flow of structured programming | optimizers | iterative solvers with approximation of differential calculus |
| computation | asymptotics of serial processors | systems | speed of light acceleration with rooflines of parallel processors |
Starting with Part I. Elements of Networks,
in §1. Self-Supervised Sequence Learning with Single-Layer Networks
we introduce the goal or evaluation of next-token prediction and train our first architectures with linear models which correspond to a single layer of a neural net.
This is followed by §2. The Massively Parallel Multidimensional Tensor where we start the implementation of our deep learning system 
borscht.
Then, in Part II. Neural Networks,
particularly §3. Self-Supervised Sequence Learning with Deep Networks,
we deepen our understanding of architecture by transitioning from linear models corresponding to single layer networks to non-linear models of multi layer networks,
followed by §4. The Engine of Automatic Differentiation
where we evolve the implementation of 
borscht to support automatic differentiation on gpu-accelerated neural network primitives.
Finally, in Part III. Scaling Networks (expand).
We will introduce such mathematical primitives by keeping our language models simple at first in Part I. Elements of Networks — namely what is called the ngram model and linear models in which the aforementioned Markov and Shannon were working on around a century ago — before diving into the design of neural network architectures (including transformers with the attention operator) in Part II. Deep Neural NetworksPhysicists learn the standard model before general relativity. Similarly, we must learn the foundations of machine learning before deep learning..
1.1.2 From Variables to Random Variables
Table of Contents (§1.1 From Certain to Uncertain Knowledge)
Consider the sentence “Hello world, nice to meet you” and feed it into GPT-2 with the words “meet you” missing. When you click the button “predict”, GPT-2 produces a list of real numbers approximately represented by floating points that are between 0 and 1 and sum (or normalize) to 1, which is called a distributiondistribution, because it is distributing truth across a weighted set of values, or in other words, it’s uncertaintyuncertainty about the world, which may or may not be truly stochasticstochasticity. Each number represents the probabilityprobability Stanford CS109 Probability for Computer Scientists | What is Probability? | Lecture 3, chancechance, likelihoodlikelihood, or beliefbelief that GPT-2 assigns to an outcomeoutcome, which in this case is the next word. The stochasticity of probability theory is central to software 2.0.
But rather than produce a distribution with two outcomes like a coin, six outcomes like a die, or fifty two outcomes like cards, it produces one for outcomes, where is the set of words in some vocabularyvocabulary. The size of GPT-2’s vocabulary is 50257, and the list of probabilities you see in Demo 1.1 are the top 10 most likely. As a first approximation, language models can be viewed as as an urn containing a ball labeled with each word in the vocabulary, where some balls are weighted heavier than others.
To be more precise, because we are passing an input sentence, such a distribution is a conditional distributionconditional distribution. That is, GPT-2 is producing the distribution of the next word conditioned on the sequence of words passed in as input, and is denoted by
and with Demo 1.1, evaluating GPT-2 is equivalent to . That is, language modelslanguage models are next-token predictors modeled by conditional distributions. More accurately, each number in that list of probabilities is the chance that GPT-2 assigns a random variablerandom variable taking on a specific outcome. A random variable is like a deterministic variable in that it can take on values, but rather than being assigned one specific value, it can possibly take on many with a certain degree of chance. A random variable’s distribution then corresponds to an array or list of values which we call a distribution because it distributes the truth or state of the random variable across many outcomes.
In the case of GPT-2, the conditional distribution ’s random variable is denoted as . To make random variables more explicit, some people will denote distributions with their random variables included, which in our case of a conditional distribution over next words, is . However, this brings us to our next point which is that with conditional distributions, the random variable being conditioned on is actually no longer random, because it is assumed that it has already taken on a value, in this case where , , . So, with more generally, there is no randomness associated with the history . Let us print the distribution over the next word that GPT-2 produces given the history from Demo 1.1:
import torch
# The same GPT-2 output distribution p(w | "Hello world, nice to") from above, top 10 of 50257 words
# (renormalized so this top-10 slice sums to 1)
vocab = ["see", "meet", "hear", "have", "be", "know", "you", "talk", "say", "get"]
probs = torch.tensor([0.387361, 0.154993, 0.152304, 0.127857, 0.057572, 0.043027, 0.039115, 0.015646, 0.013935, 0.008190])
print("Asking GPT-2 what is p(y|Hello world, nice to):")
for i in torch.argsort(probs, descending=True):
print(f"{vocab[i]} : {probs[i]:.4f}")
print(f"Total sum of p(y|Hello world, nice to): {probs.sum():.4f}")
Asking GPT-2 what is p(w|Hello world, nice to): see : 0.3874 meet : 0.1550 hear : 0.1523 have : 0.1279 be : 0.0576 know : 0.0430 you : 0.0391 talk : 0.0156 say : 0.0139 get : 0.0082 Total sum of p(w|Hello world, nice to): 1.0000
import matplotlib.pyplot as plt
order = torch.argsort(probs, descending=True)
plt.figure(figsize=(6, 3))
plt.bar([vocab[i] for i in order], probs[order], color="#0072B2")
plt.ylabel("p(y|Hello world, nice to)")
plt.title("GPT-2 next-token distribution (top 10)")
plt.tight_layout()
plt.show()
Where the state of is being spread across 50257 possible outcomes of GPT-2’s vocabulary.
Each index i of the probs array corresponds to a value in the token outcomes tokens[i],
with each probs[i] corresponding to the chance, or possibility, of the random variable taking on the value tokens[i].
That is, what connects the weighted array tokens and the state space probs is the index i.
It is more accurate then, to describe a random variablerandom variable Stanford CS109 | Random Variables and Expectation | Lecture 6 as states represented by indices ,
and the type of a distribution ends up being some function
that sends states of indices to their probabilities, such that for all
and . In general, any list of values between 0-1 and normalizes to 1 is a valid distribution.
These properties are referred to as non-negativity and normalization respectively.
We can validate that probs is a valid distribution by verifying that each probability is between 0-1 and the distribution normalizes to
1However, since we are only including the top 10 most likely words out of 50256, we’ve re-normalized probs so that the total sum of probs is 1. The original sum is 0.8181, with the other 1-0.8181=0.1819 spread across the other 50256-10=50246 unseen words..
Moreover, since the space of possible outcomes has size of 50257, the random variable is said to be
a discrete random variablediscrete random variable with a probability mass functionprobability mass function
as opposed to a continuous random variable with a probability density functionprobability density function.
experimentexperiment trialtrial frequencyfrequency
g = torch.Generator()
n = 10_000_000
target_e = "see"
# p must normalize to 1, so we renormalize our top-10 slice of the distribution
p = probs / probs.sum()
# draw all n samples at once, according to p(y|"Hello world, nice to")
# (tensors hold numbers, not strings, so we sample vocab indices rather than words)
outcomes = torch.multinomial(p, n, replacement=True, generator=g)
i_e = vocab.index(target_e)
prob_e = p[i_e].item()
freq_e = (outcomes == i_e).float().mean().item()
print(f"the expected probability of \"{target_e}\" is {prob_e}")
print(f"the actual frequency of \"{target_e}\" in {n} trials is {freq_e}")
the expected probability of "see" is 0.38736099004745483 the actual frequency of "see" in 10000000 trials is 0.38721099495887756
1.1.3 Probability as Array Programming
Table of Contents (§1.1 From Certain to Uncertain Knowledge)
Language models are next-token predictors modeled by a conditional probability distributions of a random variable, which, unlike deterministic variables,
are modeled by a list of values in which the state or truth of the random variable is distributed over.
Since the length of GPT-2’s vocabulary is 50,256 words, it’s subsequent probability distribution is a list,
or a vectorvector
and moreover, rather than use a vanilla Python list to represent such a vector, we use a 
borscht.Tensor
borscht.Tensor instead.
print("gpt-2's output distribution is stored with a torch.Tensor rather than a vanilla python list")
print(f"probs.shape {type(probs.shape)}: {probs.shape}")
print(f"probs.dtype {type(probs.dtype)}: {probs.dtype}")
print("\ngpt-2's individual probabilities are accessed by indexing into")
print(f'0 ({vocab[0]}) -> {probs[0]}')
print(f'1 ({vocab[1]}) -> {probs[1]}')
print(f'2 ({vocab[2]}) -> {probs[2]}')
gpt-2's output distribution is stored with a torch.Tensor rather than a vanilla python list probs.shape <class 'torch.Size'>: torch.Size([10]) probs.dtype <class 'torch.dtype'>: torch.float32 gpt-2's individual probabilities are accessed by indexing into 0 (see) -> 0.38736099004745483 1 (meet) -> 0.1549929976463318 2 (hear) -> 0.15230399370193481
The two primary properties of a 
borscht.Tensor
borscht.Tensor
is it’s .shape
borscht.Tensor.shape and .dtype
borscht.Tensor.dtype. evaluating to (10,) and dtype64 respectively means
it’s a vector with support in
whose real values are being approximately represented with double precision floating point numbers.
Since distributions can be viewed as vectors which in turn can be viewed as lists, probability can be viewed as none other than array programming.
More generally, a 
borscht.Tensor with .shape of (V),
corresponds to a vector with a support , where is the vocabulary.
(todo: context-length is dimensionality of ).
For convenience sake, we analyzed the top 10 probabilities with a 
borscht.Tensor whose .shape was (10,),
which corresponded to a vector .
The visual lecture from 3Blue1Brown’s mentions that vector can be viewed as a fancy word for a list — in the context of deep learning we use the term tensor rather than vector.
Vectors | Chapter 1, Essence of linear algebra (3Blue1Brown 2016)
Viewing tensors as lists is taking the data-oriented perspective — for instance probs being some vector .
However, if you squint your eyes and view the probs array from a more functional perspective, it is also a function statistical learningAlso, if you squint your eyes at the hashmap data structure (in Python a dict) with the functional perspective, it’s a function . In fact, there was a named tensor proposal, Tensor Considered Harmful (Rush 2019), for treating and viewing tensors more like dicts rather than tuples..
The notation can be viewed as a function ,
or more generally for , the function .
The two notations are equivalent!
missing notebook: /opt/build/repo/sitp/notebooks/1.1_gpt2output.ipynb
Important
Probability can be seen as array programming! Every valid distribution which is some function whose values are between 0 and 1 and normalizes to 1 has a corresponding
borscht.Tensorwith.shapeof(todo).
outcomes -> events
- todo eventevent
- todo probability of ORprobability of OR
- todo sum rulesum rule
import numpy as np
# The same <span class="smallcaps">GPT-2</span> output distribution p(w | "Hello world, nice to") from above, top 10 of 50257 words
tokens = ["see", "meet", "hear", "have", "be", "know", "you", "talk", "say", "get"]
probs = np.array([0.3169, 0.1268, 0.1246, 0.1046, 0.0471, 0.0352, 0.0320, 0.0128, 0.0114, 0.0067])
tokens_start_with_h_or_s_and_contains_a_probs = []
for token, prob in zip(tokens, probs):
stripped = token.strip()
starts_with_h = stripped.startswith("h")
starts_with_s = stripped.startswith("s")
contains_a = "a" in stripped
if (starts_with_h or starts_with_s) and contains_a:
tokens_start_with_h_or_s_and_contains_a_probs.append(prob)
prob_starts_with_h_or_s_and_contains_a = sum(tokens_start_with_h_or_s_and_contains_a_probs)
print(f"probability that word starts with letter h: {prob_starts_with_h}")
print(f"probability that word starts with letter s: {prob_starts_with_s}")
print(f"probability that word starts with letter h or s: {prob_starts_with_h + prob_starts_with_s}")
print(f"probability that word starts with letter h or s, and contains letter a: {prob_starts_with_h_or_s_and_contains_a}")
- todo: numpy vectorization
So when you ask an LLM a question, it generates a full answer (with many sentences) word by word by repeating the following loop:
- evaluating the probability of the next word conditioned on the input
- selecting (or sampling) a word. halt if the word is the special END word.
- appending it to the existing text, and evaluating the probability again with the modified input
Now that you understand the basics of language modeling, the trillion dollar question is how to produce such a conditional distribution ? In some sense that’s all there is to large language models.
1.1.4 Are You Pythonic?
Table of Contents (§1.1 From Certain to Uncertain Knowledge)
Python programming language
1.2 Next Token Prediction is Classification and Compression
In which The spelled-out intro to language modeling: building makemore (Karpathy) we introduce the terminology of software 2.0 in the context of language modeling; the joint, marginal, conditional with multidimensional arrays, reductions, and broadcasting; implement our first language model by maximizing the likelihood of biased ngrams; evaluate said model’s limits with information theoretical and probabilistic quantities such as perplexity, entropy, bias variance tradeoff, the curse of dimensionality; and finally, understand intelligence more generally as compression.
1.2.1 The Estimation of Software 2.0
Table of Contents (§1.2 Next Token Prediction is Classification and Compression)
Estimating conditional distributions is the heart of software 2.0,
where for large language models like GPT-2, such a conditional distribution is the probability of the next word given those that have already occured.
In §1.1 From Certain to Uncertain Knowledge,
we saw that with the input text "Hello world, nice to meet" as the history ,
GPT-2 produces a conditional distribution with a 
borscht.Tensor of .shape (V,) which corresponds to a vector with support in
— since we are only taking the top 10 most likely words though, .shape is (10,) with support in .
We now shift our attention from using language models to implementing ones, namely the conditional distribution GPT-2.
We will incrementally increase the expressivity of our models culminating with GPT-2’s transformer architecture in Part II. Deep Neural Networks. But for now, we start with the basics and return to first principles.
As mentioned in the previous chapter, large language models are trained using methods from the discipline of deep learningdeep learning, which in turn are based in machine learningmachine learning, which in turn are based in statistical learningstatistical learningActually, the implementation of learning is not just limited to the stochastic and infinitely continuous methods of software 2.0, more classically known as connectionism. It’s also possible to implement with logical and finitely discrete methods of software 1.0 (symbolism), albeit with much less success. See inductive logic programming also known as reverse deduction https://en.wikipedia.org/wiki/Inductive_logic_programming., which in turn are based in statistical inferencestatistical inference, which in turn employs the use of statistical estimationstatistical estimation. What are the meanings of all these terms? Roughly speaking, people in the business of estimation like AI researchers at frontier labs are given data (in statistical jargon, observe samples) such as the internet and would like to estimate distributions (of the population) from said data, namely a like GPT-2. Because distributions are functions, statistical estimation is also known as function approximationfunction approximation.
Estimation of distributions is one possible activity of making inferences from data — as opposed to simply describing data See https://en.wikipedia.org/wiki/Descriptive_statistics. — another useful form of inference is hypothesis testingSee https://en.wikipedia.org/wiki/Statistical_hypothesis_test., the process of deciding whether observed data provide sufficient evidence to reject a null hypothesis. Estimation however is the primary statistical tool used as opposed to hypothesis testing, and once a distribution like is estimated from data by research labs, users can use such a distribution in order to generate sentences — hence this most recent wave of statistical methods from software 2.0 being coloquially known as genaigenai, other previous monikers being big databig data, data miningdata mining, data sciencedata science, and pattern recognitionpattern recognition — with an inference loop, namely predicting a distribution over the next token, sampling and appending a token to the history and repeating.
Generally speaking, the difference in direction of estimating a distribution from data versus generating data from a distribution is the primary distinction between probability and statistics, although the line as we will soon see gets quite blurryIn the same way traditional data and function blurs with software 1.0, since functions are also data.. This distinction between the two is more broadly known as the difference between analysisanalysis and synthesissynthesisSee https://plato.stanford.edu/entries/analytic-synthetic/. Researchers analyze the observed data of the internet and recover an estimated distribution whereas users synthesize data and generate sentences. Sometimes they are referred to with direction in time: synthesis is the forward direction whereas analysis is the backward or inverse. Throughout the book, we will incrementally explore increasingly expressive languge models that can do a better job at recovering from data.
The availibility of said data determines the regime of statistical learning. That is, if sample data is available in the form of input-output pairs , then the regime is known to be supervised learningsupervised learning or self-supervised learningself-supervised learning, for the machine that is attempting to learn a distribution via induction is being supervised with the expected outputs, whereas the regime where such data is unavailable is known as unsupervised learningunsupervised learning. Where in unsupervised learning the goal of inference is that of understanding like clustering or dimensionality reduction, in the regime of supervised learning the goal is the aforementioned statistical estimation of a function that predicts the expected output given the input. For instance, with the following self-supervised dataset
missing notebook: /opt/build/repo/sitp/notebooks/1.2_bigram.ipynb
Moreover, the regime of supervised learning is further refined depending on the kind of output the estimated function is predicting. In the case of a categorical output, the supervised learning is known as classificationclassification for the machine’s goal (the estimated function’s output) is trying to classify the input’s category. Modeling languagelanguage models as next-token prediction is in fact an instance of classification since it is classifying the next most likely token given an input sentence This stands in contrast to the case when the output that the estimated function is predicting is quantitative, which is known as regressionregression.
Somewhat more confusingly, depending on the domain or context of statistical modeling, the terminology for inputs and outputs might be further refined. That is, inputs and outputs are also respectively referred to as independent variableindependent variable and dependent variablesdependent variable, as well as predictorspredictors and responsesresponses in the context of experimental design. They can also be referred to as featuresfeatures and targetstargets. The difficulty of naming is not limited to the discipline of programmingSee https://martinfowler.com/bliki/TwoHardThings.html. Whatever you want to call them, at the heart of statistical learning, machine learning and deep learning is the activity of estimation, or the approximation of functions with inputs and outputs . two cultures of statisticstwo cultures of statistics
Warning
Inference is an overloaded term, and hence ambiguous without context! In generative AI the term inference refers to the forward process of evaluating a population’s distribution in order to generate samples via prediction. In classical statistics the term inference refers to the backward process of inferring a population’s distribution from observed samples
But for the classification of language modeling, how do we reconcile the goal of estimating some function , when GPT-2 is the probability distribution
which is a function of type ? The way we reconcile such a type mismatch is to map over words from their string representations in to their index representations in with a dict[str, int] instantiated by {c:i for i,c in enumerate(v)}
missing notebook: /opt/build/repo/sitp/notebooks/1.2_bigram.ipynb
However, note that the input type of a next-token classifier are sentences . (todo: BTD)
Let us now estimate our first language model
1.2.2 The Joint, Marginal, Conditional, and Chain Rule
Table of Contents (§1.2 Next Token Prediction is Classification and Compression)
Software 2.0 models language as a next-token predictor which means estimating a classifier which assigns a next word given a sentence where all under a self-supervised setting with access to input-output paired data .
Let us first start by generalizing the notion of a distribution of a single random variable which corresponds to a flat array of real-valued numbers satisfying non-negativity and normalization to that of a joint distributionjoint distribution Stanford CS109 Probability for Computer Scientists | Joint Distributions | Lecture 11, defined as a high dimensional function with type which corresponds to a multidimensional array. Both map the values of random variables to their probabilitiess. That is, evaluating
produces a single probability for random variable taking on the value and taking on the value and so on, up until the random variable taking on the value . Because the joint can be a distribution of two random variables, three, up to an arbitrary t, it’s often vectorized as .
In the case of (probabilistic) language modeling, the primary joint distribution of interest is the probability of a fixed t lengthed sentence occuring, which is or with , where the ’th random variable represents the ’th word in the -lengthed sentence. For instance, is the probability of the complete sentence "Hello nice to meet you" (todo, instead of distribution over words, it’s a distribtion over sentences).
Warning
Note with the random variables being modeled the way they are (that is, with order) that the joint is commutative. For instance, with , . Semantically speaking, is saying that the probability of the first word being “hello” and the second word being “world” is equal to the probability of the second word being “world” and the first word being “hello”. This holds more generally for any permutation of .
With us defining the notion of a joint probability as a multidimensional array, it’s probably no surprise that the joint distribution corresponds to a 
borscht.Tensor with .shape (V,...,V) where .ndim is t. That is, .shape is a t-lengthed tuple. Take a look at Figure 1.2.2 below:
Figure 1.2.2
In particular, let us consider the joint distribution of two random variables , whose 2 dimensional surface can be plotted. This distribution has type , which corresponds to a 
borscht.Tensor with a .shape of (d1,d2) — a matrixmatrix supported in , or in other words, a by rectangular array that maps indices in to their values in .
Because the array for a matrix can be visualized as a rectangle when taking the data-oriented perspective, the first dimension is usually referred to as the number of rowsrow,
and the second dimension is referred to the number of columnscolumn. This naming of course breaks down for tensors with a higher number of dimensions.
The code example initializes a matrix with 
borscht.zeros((n,m)), and plots the surface with ax.plot_surface().
import borscht
x = borscht.zeros((d1,d2))
print(x)
The joint of three random variables is plotted as what is known as a voxel plot (it’s difficult to see 4 dimensions alltogether), which corresponds to a 
borscht.Tensor with a .shape of (d1,d2,d3) — a tensortensor supported in , a cubic array that maps indices in to their values in . (todo, remark on naming with tensor.)
Similarly, the tensor is initialized with 
borscht.zeros((n,m,p)), is filled with values, and is plotted with ax.voxels(x).
import borscht
x = borscht.zeros((d1,d2,d3))
print(x)
Important
Once again, probability can be viewed as array programming!
(todo, warning on
With the notion of a joint distribution now defined, the actual joint of interest to language modeling is the probability of a sentence taking on specific words where , let so that we can consider the joint of two random variables ,
which corresponds to a matrix represented by a 
borscht.Tensor with .shape of (V,V), in this case (5,5).
In Figure 1.2.3, the surface on the left and the matrix on the right represent the same distribution . These values are one possible assignment to the function body , or equally, one possible assignment to the 
borscht.Tensor. For instance, we see that . (todo, it’s really . (todo, table is technically infinite. t is not fixed.). Note that the corresponding is provided, even though the distribution is defined on .
Figure I.2.3
Note
In SITP, we will incrementally increase the expressivity of our next-token predictors or classifiers in order to model language more accurately. Throughout the book, we will maintain a visual language of color in addition to an algebraic one to indicate a consistent purpose or role regardless of what space an object is represented with. For instance, input sentences will be coded with blue whether they are strings indices one hot vectors , or soon to be embeddings . Without loss of generality, the same holds for output tokens except coded with orange. More colors will also be introduced later on. This can be toggled at anytime by clicking the located in the top left corner next to the theme picker.
- modify left figure to highlight the 0.15 and 0.16s
- why is the marginal a uniform (0.2)?
For a single random variable , we can see that the truth of it’s state is indeed distributed across the vocabulary . For instance, scanning the blue arrow from top to bottom, we see that state of random variable is distributed across five words. In particular, for one of those values which corresponds to , it’s state in of itself is distributed across with the orange arrow from left to right. For instance, the chance of "to meet"should be more likely than "to to".
The code for this joint is the transliteration of the matrix from Figure I.2.3 into 
borscht.Tensor with .shapeof (5,5):
missing notebook: /opt/build/repo/sitp/notebooks/1.2_bigram.ipynb
Warning
Let us digress on two technical aspects of
borscht: shape suffixes and indexing.
Digression 1: Shape Suffixes
The first place to draw your attention to is in the particular choice of naming the joint distribution as p_VV.
The _VV suffix is documentation-based coding brought back from the deadSee Hungarian notation https://en.wikipedia.org/wiki/Hungarian_notation. Also see Google’s C++ Style Guide — a somewhat representative and perhaps canonical standard of most source conventions — frown on Hungarian notation. Finally see Tensor Considered Harmful (Rush 2019) and Named Tensor Notation (Chiang et al. 2021).
as a reading aid to humans and fellow LLMs to denote that the 
borscht.Tensor has a .shape of (V, V), where in this case V evaluates to 5. We even assert such fact with assert p_VV.shape == (V, V).
Such judgement can actually be enforced by typechecking dynamically at runtime with the jaxtyping Python package, particularly Float[np.ndarray, "V V"] (expand on jaxtyping and python typechecking):
missing notebook: /opt/build/repo/sitp/notebooks/1.2_bigram.ipynb
The function printp_VV takes a parameter p_VV with type Float[np.ndarray, "V V"],
so the first invocation with the argument p_VV itself passes.
Mutating p_VV to alias a 
borscht.Tensor with .shape (3,)
and applying the function a second time results in a type error.
Digression 2: Indexing
Second, notice how the value of
is retrieved with p_VV[toi, meeti]. (todo, curry by indexing into first or second):
missing notebook: /opt/build/repo/sitp/notebooks/1.2_bigram.ipynb
Note
Digression is now over.
Now, with 
borscht technicalities of shape suffixes and indexing aside, let us move onto the next probabilistic concept of the marginal.
From this joint distribution of two random variables , we can recover both distributions of single random variables and .
Consider again that second “level” of distribution. That is, the specific state of being distributed in of itself across the first row of the matrix according to . We can “take out” the randomness of by summing all the entries in the first row, and no longer have this second level or axis of state being distributed, and are simply left with . In general, for any , we have that . Note that is fixed! In the context of recovering a distribution of a single random variable from a joint distribution, such single random variable distribution is referred to as the marginal distributionmarginal distribution.
Transliterating this to 
borscht, we use 
borscht.Tensor.sum()
borscht.Tensor.sum reduce operationreduction (expand):
missing notebook: /opt/build/repo/sitp/notebooks/1.2_bigram.ipynb
(todo: generalize for randvars) torch.sum
After generalizing the notion of a distribution into a joint distribution, and recovering marginals from said joint, we are now finally ready to backtrack to refining the notion of the conditional. .
conditional distributionconditional distribution Stanford CS109 | Conditional Probability and Bayes | Lecture 4
so, the conditional is defined as the joint over the marginal distribution that is being conditioned on. chain rulechain rule product ruleproduct rule (decomposing a joint of sentences with a chain of conditionals over words requires proof (ntermezzo).) with an inference loop, namely predicting a distribution over the next token, sampling and appending a token to the history and repeating.
We can further clarify our notion of language modelslanguage models to next-token predictors performing classification which then auto-regressively feeding such classifying prediction into the next evaluation via chain or product rule.
More generally, broadcastingbroadcasting broadcast divide
There is a rule you should learn at long last,
combination of tensors the task.
Dims right-aligned,
extra left 1s assigned,
match paired dimensions: Broadcast!
9 x 1 x 3 1 x 8 x 1 9 x 8 x 3
Tensor Puzzles (Sasha Rush, Marcos Treviso)
missing notebook: /opt/build/repo/sitp/notebooks/1.2_bigram.ipynb
1.2.3 Estimating Markov Biased Histograms by Counting
Table of Contents (§1.2 Next Token Prediction is Classification and Compression)
We know that denotes the conditional distribution of the next word given the history of sentence thus far, we know this distribution corresponds to a 
borscht.Tensor with .shape (V,), and we know the function type is (todo: can be viewed as ) which maps sentences to distributions over next words, but how do we go about estimating the distribution ?
When initially describing the distribution of next possible tokens with the stochasticity of probability theory, we also mentioned other phenomena like coin flips, die rolls, and card draws that are modeled stochastically as well and thus with distributions. In the case where such physical objects are deemed to be fair — that is, no one has weighted the coin, die, nor rigged the deck — we can assume that all outcomes are equally likely and subsequently calculate the probability of any event (todo, go write that exposition) is total number of outcomes in the event over the number of outcomes in the sample space.
However, it’s clear that language is not a stochastic process with equally likely outcomes. For instance words “the”, “is, “a” are more likely than “quixotic” for instance. It is for this reason that the first idea to recover or estimate is by simply countingcounting the number of times the event of interest occurs from some dataset? sample? and dividing it by the total number of events in the event space, thereby calculating the relative frequency estimaterelative frequency estimate:
Taking the internet as our dataset, this ratio is asking out of all the sentences that start with “Hello world nice to meet”, how many times does “Hello world nice to meet you” occur? (todo, define unbiased estimator. in the limit..) While the relative frequency estimate is an unbiased estimatorunbiased estimator, the problem is that language as a phenomena continually evolves, grows, and adapts itself. The fact that natural language can be easily modified and innovated on by new generations of our culture is a feature rather than a bug. While new slang is invented every day, the classic examplar for the problem of using a relative frequency estimate is the following sentence posed by Noam ChomskySee https://en.wikipedia.org/wiki/Colorless_green_ideas_sleep_furiously:
Although such sentence now exists on the internet because of Noam Chomsky, the sentence shows one that is syntactically valid but perhaps not semantically meaningful, which, very much resembles the essence of slang. Now, since estimating with a relative frequency estimate is intractable, let us explore alternative ways. This motivates the need to dig deeper into the precise defintion of the conditional, what is what we explore here in 1.2.2 The Joint, Marginal, Conditional, and Chain Rule
Now that we understand the process of modeling language as a next-token predictor is to estimate a classifier ) which assigns a next word given a sentence where all under a self-supervised setting with access to input-output paired data , and moreover, that the conditional is precisely defined as the joint over the marginal, can we simply evaluate the broadcasted divide of the joint over the marginal?
Rather than strive for perfection, we can achieve the good by introducing some biasbias, known as the Markov assumptionmarkov assumption.
- (todo, explain data d)
- counting is the maximum likelihood estimatemaximum likelihood estimate (MLE).
- simplest idea: unbiased estimator count the relative frequency (which is the MLE)
- need chain/product rule here. but do not introduce bayes rule, for that will introduce discriminant vs generative modeling.
- intractable, so need to introduce bias. with markov assumption (truncated conditional in chain rule)
missing notebook: /opt/build/repo/sitp/notebooks/1.2_bigram.ipynb
missing notebook: /opt/build/repo/sitp/notebooks/1.2_bigram.ipynb
missing notebook: /opt/build/repo/sitp/notebooks/1.2_bigram.ipynb
1.2.4 The Scoring of Software 2.0
Table of Contents (§1.2 Next Token Prediction is Classification and Compression)
- use perplexity following jurafsky, even though karpathy only uses log likelihood?
- karpathy scores the bigram model with log likelihood, and maintains the log likelihood for the parameterized single layer neural net
- Scoring software 2.0 involves probabilities of probabilities
- prediction is compression is intelligence
Scoringscore such estimation involves probabilities of probabilities.
maximum likelihood estimation Stanford CS109 Probability for Computer Scientists | M.L.E. | Lecture 21 (Piech)
missing notebook: /opt/build/repo/sitp/notebooks/1.2_bigram.ipynb
Reinventing Entropy | Compression is Intelligence Part 1 (3Blue1Brown 2026)
1.2.5 The Evaluation of Software 2.0
Table of Contents (§1.2 Next Token Prediction is Classification and Compression)
1.2.6 Summary and Further Reading
(Eisenstein 2019)’s Chapter 6. Language Models; (Jurafsky and Martin 2026)’s Chapter 3. N-gram Language Models;
(further reading for torch)
1.3 Parameterizing Classification with Discriminant Functions
In which we parameterize classification with a three step process of parameterization, scoring, and optimization; in particular, by directly optimizing the mean squared error of the perceptron; and understand the perceptron’s classifying by expanding our notion of vectors from vectors as lists to vectors as points, enabling not only array programming with distributions, but also with geometry
1.3.1 The Parameter Estimation of Software 2.0
Table of Contents (§1.3 Parameterizing Classification with Discriminant Functions)
Parameterizing classifiers and estimating such parameters forms the basis of statistical machine learning implemented by programming software 2.0 with neural networks. In §1.1 From Certain to Uncertain Knowledge and §1.2 Next Token Prediction is Classification and Compression we used and implemented our first classifier which modeled language stochastically as next-token prediction, assigning an output token for a given input sentence from self-supervised data . Particularly, we modeled as a bigram model which introduces bias with respect to the product rule via Markov assumption of truncating the history of conditionals, where such bigrams were estimated by counting relative frequencies with a row stochastic matrix . The definition of the bigram model is provided on the left
Whereas in §1.3 Parameterizing Classification with Discriminant Functions, we now change our approach to language modeling next-token prediction (and statistical machine learning more generally) from that of counting to parameterizingparameterization, meaning to define a function with parametersparameters in addition to it’s input . The type of the function on the right is changed to take in a parameter and an input sentence in order to produce an output token . However, passing in as an argument on every function evaluation is cumbersome since, after we find a good set of parameters , they in fact do not changeUnlike the human brain, which is constantly being reconfigured and rewired!. Rather, what we would like is to instantiate the function with parameters once, and then evaluate such function whenever we needed to predict the next token. The solution to this is to change the type of to
which we can partially applypartially apply by passing in as input, and receive the next-token predicting function
itself. In other words, we have curriedcurried the type into a higher order functionhigher order function which can return function themselves.
Transliterating a toy parameterized model which multiplies an input with a parameter to 
borscht then looks like
import borscht
import borscht.nn as nn
class MyParameterizedModel(nn.Module):
def __init__(self, theta):
self.theta = theta
def __call__(self, x):
return x*self.theta
theta = 42
model = MyParameterizedModel(theta)
y = model(1)
print(y)
where is the first evaluation with denoting the next-token predictor , and is the second,
These correspond to the Python’s class __init__ and __call__ methods respectively.
When functions are parameterized, they are denoted as or to reflect such.
Now, because the model is characterized or determined by said parameters , such parameters must be estimatedparameter estimation, usually from the dataset . The way this is done is to score such parameters with an objective known as the loss functionloss function, and subsequently find the parameters that optimizeoptimization such loss function, a process denoted by . That is, the problem of finding a good model reduces to finding a good set of parameters according to the loss, which is comprised of a three step process:
- parameterize the architecture with
- score such parameters with
- optimize such parameters with
todo. fri oct 2
- risk
- step zero?
The parameterization of functions and the subsequent parameter estimation are the primary workhorses in the function approximation of statistical learning. However with deep learning, we concern ourselves primarily with the class of models that are biologically inspired by the brain’s neuroanatomy. Although far from realistic, a neural network models the signals between the axon of one neuron to the dendrite of another with a set of weightsweights and biasesbias where . Such models are referred to as deep neural networks, in which we start by training single layer networks comprised of perceptronsperceptrons Stanford CS229: Machine Learning | Lecture 5 - Perceptron and Logistic Regression. The computational circuit of a perceptron displayed on the left side of Figure 1.3.1, where a perceptron produces a real-valued scalar by taking the sum of with parameters and .
Figure 1.3.1
The perceptron is a weighted sum of ’s and ’s,
which can be rewritten as the dot productdot product of the two vectors and , and trivially transliterates to
borscht.dot()
import borscht
import borscht.nn as nn
class Perceptron(nn.Module):
def __init__(self):
self.w = borscht.randn(d)
def __call__(self, x: borscht.Tensor):
return borscht.dot(self.w, x)
model = Perceptron()
y = model(x)
print(y)
where the bias term is folded by prepending and into and respectively, subsequently updating the dimension from to . With respect to the three step process, we have completed the first one of parameterizing the architecture . But before moving on to the second and third steps of scoring such parameters with and optimizing them with , you may have noticed that the domain of is rather than sentences . While we are now familiar with high dimensional arrays, a matrix and it’s row vectors were being used to model the stochastic matrix where discrete word indices were encoded into and out of such matrix with . Moreover, how is it’s codomain of classifying some category ?
Warning
So how is the perceptron taking in vectors directly,
and how does producing scalars perform a classification?
To answer this we need to take the proverbial red pill,
let us go deeper into the rabbit hole of theborscht.Tensor.
1.3.2 Analytic Geometry as Array Programming
Table of Contents (§1.3 Parameterizing Classification with Discriminant Functions)
Parameterizing classifiers and estimating such parameters forms the basis of programming software 2.0 with the statistical machine learning of neural networks. The primary class of parametric models under concern is that of the biologically inspired perceptrons , introduced in §1.3.1 The Parameter Estimation of Software 2.0. However, before proceeding to score and optimize the perceptron’s parameters, we realized the domain and codomain of the perceptron was and .
How is it possible that the perceptron is evaluating next-token prediction just like our bigram model, when it takes in some high dimensional array in from the get go (whereas the bigram model only used the higher dimensional matrices and arrays for the row stochastic matrix ?) and produces some quantitative output that is usually indicative of regression, rather than classification?
For now, we reconcile how the real-valued output is parameterizing classification with a direct decisiondecision without inferringinferring a probability distribution. We square the latter with the perceptron in §1.4 Parameterizing Classification with Probabilistic Models thereafter.
The red pill needed for such reconciliation will deepen our understanding of vectors and matrices, both represented by the multidimensional array of the 
borscht.Tensor.
Important
The red pill is one which expands our previously conceived notion of vectors from the so-called computer scientist view of tensors as lists, to that of the geometrical view with vectorsvector as points in space. It is quite likely that you may have encountered vectors initially as geometrical points or lines, rather than as computational lists. Because of the inherent difficulty to visualize vectors that have support in high dimensional spaces where is greater than 3, we will start with an example of vectors in .
Let us consider a simplified language model setting where the goal is to perform binary classification
with a context or history of length 1 on a vocabulary of length 2 where . And moreover, recall that we are parameterizing such next-token classification with weights and biases via perceptron , corresponding to def __call__(self, x): borscht.dot(self.w, x).
Starting with reconciling the type mismatch of our classifier with the perceptron’s domain of , we continue to map words from their string representation in to their positional indices in , however, there is an additional transformation of the input thereafter from to . The simplest idea for such a transformation is to map or embedembedding the position index to a -dimensional vector with all entries set to 0 except for the ’th dimension which is set to 1. Such a vector is referred to as a one-hot vectorone-hot vector and is known as a localist representationlocalist representation of a word, where each word’s meaning is represented by itself in isolation without a regard to the whole lanugage of English as a whole — for instance, adding two one-hot vectors is meaningless, since any vector with more than one non-zero entry is ill-defined. The final series of representational transformations of the input then looks like
where the corresponding function in borscht to map indices to their one hot vectors is borscht.nn.functional.one_hot(tensor, num_classes=-1) which takes a LongTensor with index values of shape (*) and returns a tensor of shape (*, num_classes).
import borscht
import borscht.nn.functional as F
X = borscht.Tensor([0, 1, 2, 3])
X_BV = F.one_hot(X)
When we map our words to their one-hot vector representations with the earlier constructed hashmap c2i,
the point for the word is located on the -axis and the point for the word on the -axis.
This is can seen in Figure 1.3.2
Figure I.3.2 On the left hand side the one hot vectors in are displayed for the strings . On the right hand side a decision boundary linearly separates into decision regions and .
Now that we’ve reconciled the type mismatch of our classifier with the perceptron’s domain , let us clear our confusion for the perceptron’s codomain of . In terms of a desirata for the decision step — that is assigning the input some outcome — it would be nice if we could divide the plane into two decision regionsdecision regions and associated with the output categories of and respectively with some line acting as a decision boundarydecision boundary such that our classifier assigned the next word given for , and perhaps given for Bear with the toy example, for although “york new” is non-sensical, it allows us to focus our attention on the mechanics of the perceptron itself..
Rather than define the decision boundary with an explicit equation for a line, another possible idea is to define it as the set of all points that are perpendicularperpendicular to the weight vector .
length(norm) = sqrt[angle(inner/dot product)]
How do we define the notion of perpendicularity without using our eye balls?
- rotationrotation
- orthogonal projectionorthogonal projection
foobar
- distance with metricmetric
- length with normnorm
- length with Euclidean normEuclidean norm
- length with Manhattan normManhattan norm
- dot productdot product
- inner productinner product
- similarity with angleangle
- dot productdot product =
- pendicularity with orthogonalityorthogonal
and the final classifier has the form where is some function
Linear transformations and matrices | Chapter 3, Essence of linear algebra (3Blue1Brown 2016)
1.3.3 The Optimization of Scores in Software 2.0
Table of Contents (§1.3 Parameterizing Classification with Discriminant Functions)
Coding function bodies with assignment and control flow such as conditionals, loops are used to program software 1.0 whereas optimizing loss functionsoptimization such as the squared error losssquared error loss are used to program software 2.0.
baz
- least squares lost per
- when d=1, this is finding the line of best fit? (with bias)
- but then we use the line of best fit as a discriminator with
- is a linear system on =
- min
1.3.4 Evaluation with Bias Variance Tradeoff
Table of Contents (§1.2 Next Token Prediction is Classification and Compression)
1.3.5 Summary and Further Reading
(Duda et al. 2001)’s Chapter 5. Linear Discriminant Functions; (Hastie et al. 2009)’s Chapter 3. Linear Methods for Regression; (Bishop 2006)’s Chapter 3. Linear Methods for Regression; (Murphy 2022)’s Chapter 11. Linear Regression
(Goodfellow et al. 2016)’s Chapter 2. Linear Algebra and Chapter 5. Machine Learning Basics; (Murphy 2022)’s Chapter 4: Statistics and Chapter 7. Linear Algebra
(Bishop 2006)’s Chapter 2. Probability Distributions; (Goodfellow et al. 2016)’s Chapter 3. Probability and Information Theory; (Murphy 2022)’s Chapter 2. Probability: Univariate Models, Chapter 3. Probability: Multivariate Models
(further reading for torch)
1.4 Parameterizing Classification with Probabilistic Models
In which we…
- 1.4 Parameterizing Classification with Probabilistic Architectures
- 1.4.1 Discriminative vs Generative Modeling
- 1.4.2 Parameterizing Discrimination with Logistic Regression
- 1.4.3 Optimizing Cross Entropy Loss with Iterative Reweighted Least Squares
- 1.4.4 Evaluation with Bias Variance Tradeoff
- 1.4.5 Parameterizing Discrimination with Naive Bayes
- 1.4.6 Summary and Further Reading
1.4.1 Probabilistic Architectures: Discriminative vs Generative
In machine learningmachine learning, there are actually three ways to estimate a classifier. Recall that the definition of the conditional distributionconditional distribution is the joint over the marginal , and decomposing such joint with the product or chain rule starting with gives us a relationship of what is known as bayes rulebayes rule
Because the bigram model first produces an inferredinference conditional distribution which expresses it’s uncertainty of the next token followed by decidingdecision a specific token from such a distribution, the bigram classifier is an instance of a probabilistic architectureprobabilistic architecture. namely with a discriminative probabilistic modeldiscriminative probabilistic model
And so an alternative approach to modeling directly by (expand), the conditional distribution can be modeled as and is known as a generative probabilistic modelgenerative probabilistic model because it provides the opportunity to generate samples of the input space with where in the case of language modeling the input space is the set of all possible sentences ).
Finally, the inference stage can be skipped alltogether by simply discriminating a specific in one step (in the case of language modeling, the output space is the set of all possible next tokens ). Models that only consist of that one step are known as discriminant functionsdiscriminant functions and is where we actually begin our discussion of parametric models namely with linear regressionlinear regression corresponding to a single neuron, even though classification and regression were differentiated earlier as producing qualitative and quantitative outputs. We will revisit parameterizing the architecture of a discriminative probabilistic model with logistic regression in 1.4 Parameterizing Classification with Probabilistic Architectures.
| non-parametric | parametric | |
|---|---|---|
| discriminative probabilistic model | bigram | logistic regression |
| generative probabilistic model | N/A | naive bayes |
| discriminant function | N/A | linear regression |
which involves a two stage process of specification and implementation.
First, the distribution of must first be specified as a family of distributions parameterized by a set of parameters , followed by modeling itself as some parameterized function class
Our choice for is the Gaussiangaussian distribution with a meanmean and variancevariance where . That is, we are assuming that the output words are normally distributed conditioned on the input sentences .
whose parameters in turn are modeled as which is chosen to be the perceptronperceptron, a single layer neural network of weightsweights and biasesbiases where . That is, not only are we assuming that the outputs are normally distributed conditioned on the outputs , but also that the parameters to such a distribution can be modeled by a neural network .
where in turn is a function characterized or determined by it’s own set of parameters in addition to the input , and is denoted as or to reflect such. That is, is some function that is curried with parameters and returns a map from inputs to outputs , which is reflected in 
borscht.nn.Module where a model is first instantiated with Python’s builtin __init__, and subsequently evaluated with __call__.
With deep learning, often takes the form of a neural network parameterized by weightsweights and biasesbiases such that . In §1.3 and §1.4 we will understand and train single layer networks for to prepare us for the various network architectures explored in §3. Self-Supervised Sequence Learning with Deep Networks.
Figure 1.3.1
So, after the model family or class has been specified via parameterization, a specific model is implemented or instantiated by continuing to score the quality of the output with the likelihood (TODO) but rather as a function of parameters and moreover, optimizingoptimization said scoring function and denoted by . This process is referred to as parameter estimationparameter estimation, or more specifically, maximum likelihood estimationmaximum likelihood estimation (MLE)
Figure 1.3.2
1.4.1 Probabilistic Architecture with Logistic Regression (Old)
Table of Contents (§1.3 Parameterizing Classification with Discriminant Functions)
In contrast, in this chapter and for deep nets more generally, we will estimate by parameterizingparameters probabilities rather than counting them, namely with the weightsweights and biasesbias of a logistic regressionlogistic regression modelPerhaps logistic classification serves as better terminology, but we will shortly see why it’s called regression which can also be viewed as a single neuronneuron Stanford CS109 Probability for Computer Scientists | Logistic Regression | Lecture 24 Stanford CS229: Machine Learning | Lecture 5 - Perceptron and Logistic Regression from a deep net. That is, some function , which we will take the remainder of §1.3.1 Architecture with Logistic Regression to better understand.
A circuit for the logistic regression model is shown on the right hand side of Figure 1.3.1. Rather than produce the orange output with a stochastic matrix containing normalized relative frequency counts, the logistic regression model produces it by passing the blue input into the green summing and squashing functions and with the sum being configured — in this case weighted — with purple parameters and . Intuitively speaking, by parameterizing what we mean is that we will assume or model as some function that has configurable parameters. You can view the logistic regression model on the right as adding some indirection between the blue input and orange output with green machinery configured by purple parameters. The problem of training a good language model then reduces to that of finding a good set of parameters, in this case . That is, the process of training is parameter estimation.
Scanning from left to right of the neuronal circuit depicted in Figure 1.3.1,
we first see that the input word "to" to the conditional probability that the logistic regression is modeling is some column vector with support in .
So, starting from the string "to", we continue to map the string to it’s index in the vocabulary with some hash map , but then we need an extra step that maps the index to some vector .
If you pay closer attention to the circuit, you will see that only whereas all the other . This is called a one-hot vectorone-hot vector, which semantically represents the same positional information within the vocabulary except vectorizes that information continuously with support in rather than discretely in . Because of this, the conditional probability is denoted as .
Like 1.2_bigram.ipynb, the first cell in 1.3_logistic_regression.ipynb performs the data processing, except with the additional step of tensorizing integer indices over to one-hot vectors with F.one_hot(torch.tensor(indices)):
missing notebook: /opt/build/repo/sitp/notebooks/1.3_logistic_regression.ipynb
missing notebook: /opt/build/repo/sitp/notebooks/1.2_bigram.ipynb
So far we in the logistic regression notebook we have the same dataset, vocab and c2i. But because logistic regression takes in continuous inputs rather than discrete,
one more step must be perform in its data processing,
namely mapping (tensorizing) integer indices to their one-hot vectors (local, however).
We construct list[int] first and convert to tensor’s second,
for tensor.append()… (todo)
missing notebook: /opt/build/repo/sitp/notebooks/1.3_logistic_regression.ipynb
Where first five ’s are plotted as a heatmap with plt.imshow.
Continuing our left to right scan of the logistic regression model depicted in Figure 1.3.1,
the next aspect to the neuronal circuit is the core machinery which the one hot vectors are passed through, namely taking a weighted sum whose real-valued output is then mapped to a number between 0 and 1 with a squashing function . That is, the logistic regression assumption is .
Figure I.2.3
That is, we model as follows:
One small adjustment we can make to is to fold the final bias term into the vector as , increasing the total dimensionality so that , and adjusting accordingly so that and . Then, the equation of our line with bias is simply parameterized as
missing notebook: /opt/build/repo/sitp/notebooks/1.3_logistic_regression.ipynb
1.4.2 Optimizing Cross Entropy with Iterative Reweighted Least Squares
Table of Contents (§1.3 Parameterizing Classification with Discriminant Functions)
But what is cross-entropy? | Compression is Intelligence Part 2 (3Blue1Brown 2026)
missing notebook: /opt/build/repo/sitp/notebooks/1.3_logistic_regression.ipynb
1.4.3 Evaluation with Bias Variance Tradeoff
1.4.4 Summary and Further Reading
(Eisenstein 2019)’s Chapter 2. Linear text classification; (Jurafsky and Martin 2026)’s Chapter 4. Logistic Regression
(Hastie et al. 2009)’s Chapter 4. Linear Methods for Classification; (Bishop 2006)’s Chapter 4. Linear Methods for Classification; (Goodfellow et al. 2016)’s Chapter 5. Machine Learning Basics; (Murphy 2022)’s Chapter 10. Logistic Regression and Chapter 12. Generalized Linear Models
(MacKay 2003)’s Chapter 1. Introduction to Information Theory and Chapter 2. Probability, Entropy, and Inference; (Murphy 2022)’s Chapter 6: Information Theory
(further reading for torch)
1.4.4 bogus below
1.4.4 Architecture with Linear Regression
In the previous two chapters, we estimated the bigram and linear classifiers from self-supervised data
which predicted the next word given a sentence . For the inference step, the core machinery of the bigram model was the row stochastic matrix which corresponded to a 
borscht.Tensor with .shape of (V, V).
In contrast, the parameterization of next token prediction by the logistic regression model used a weighted sum which was passed into a squashing function, namely the generalization of the sigmoid which was the , ending with the scoring function of the cross entropy loss (todo).
This corresponded to (todo)
In fact, the linear classifier of multi-class logistic regression corresponds to a single layer of a deep neural network, and architecturally speaking, you are ready to move on to §3. Self-Supervised Sequence Learning with Deep Networks in Part II. However, there is more foundation for us to build, namely with the parameterization of quantification rather than classification with the linear regression modellinear regression model, and generalizing the family of linear models with the notion of generalized linear modelsgeneralized linear models. (todo, foreshadow svd being approximated in muon with newtonschulz5?)
Let us begin with the neuronal circuit for linear regression, which is in fact a simplification of the logistic regression model. Namely, after the weighted sum, there is no squashing.
Recall from §1.2.1 The Estimation of Software 2.0 that the regime of regression is when the function to be estimated is of the form where .



Similarly to learning distributions from data, learning functions from data roughly follows the three step process of selecting a model for the task , a performance measure , and optimizing such measure on experience . Let’s take a look at the linearly modeling regression with squared error loss, a problem which straddles tools from all three languages of probability theory, linear algebra, and calculus.
For the task of house price prediction, the input space is the size in square feet, and the output space is the price, meaning that the function which needs to be recovered has the type of . An assumption about the structure of the data needs to be made, referred to as the inductive bias. The simplest assumption to make is to assume that there exists some linear relationship between the data and parameters so that ends up being modeled as an inner product plus bias, parameterized as :
One small adjustment we can make to is to fold the final bias term into the vector as , increasing the total dimensionality so that , and adjusting accordingly so that and . Then, the equation of our line with bias is simply parameterized as
Semantically speaking, each property of the input house is weighted by some weight , adjusted accordingly during the learning process so that it accurately reflects the property’s influence on the output price when evaluating a prediction . We can evaluate on all with a randomly intialized to ensure we’ve wired everything up correctly.
However, while specifying the equation of the linear regression model on paper screen
and subsequently evaluating the computation manually might have been sufficient
for pre-computational mathematicians such as Gauss and Legendre,
the discipline of statistical learning is entirely predicated on computational methods.
Let’s continue to use PyTorch’s core n-dimensional array datastructure with torch.Tensor,
this time modeling a function rather than some distribution :
(todo: maybe change example to closure following torch.nn)
{{#include ../../teeny/examples/2.2-regression.py:1:4}}
in which we can update the names of our torch.Tensor variables with the ranks of their vector
spacesReferred to as Shape Suffixes, described by Noam Shazeer, an engineer from Google
to clarify what dimensions these operations are evaluated on:
{{#include ../../teeny/examples/2.2-regression.py:5:8}}
and finally, actually enforce them via runtime typechecking with the jaxtyping package
(try swapping the return statements below to trigger a type error with the return type):
{{#include ../../teeny/examples/2.2-regression.py:9:19}}
Let’s now sanity-check that f_typechecked is wired up correctly
by evaluating it on all with random parameter w_D.
(todo: use actual housing data)
{{#include ../../teeny/examples/2.2-regression.py:29:41}}
random weight vector: tensor([-0.3343, 0.0768, 2.7828, -0.1331])
expected: $500000.0, actual: $-492.46
expected: $800000.0, actual: $-690.89
expected: $250000.0, actual: $-258.08
Clearly, these outputs are unintelligible, and will only become intelligible with a “good” choice of parameter .
Before we find such a parameter, let’s make one more modification to our function
to increase it’s performance.
Rather then evaluating f_typechecked on every input xi_D in the dataset zip(X_ND,Y_N) sequentially with a loop,
we can evaluate all outputs with a single matrix-vector multiplication by updating ’s function definition to the following
and the corresponding PyTorch updated to
{{#include ../../teeny/examples/2.2-regression.py:21:27}}
Now that we’ve selected our model for the task , we can proceed with selecting our performance measure which the learner will improve on with experience .
1.4.5 Loss Function with Least Squares
After the inductive bias on the family of functions has been made, the learning algorithm must find the function with a good fit. Since artificial learning algorithms don’t have visual cortex like biological humans[], the notion of “good fit” needs to defined in a systematic fashion. This is done by selecting the parameter which maximizes the likelihood of the data . Returning to the linear regression inductive bias we’ve selected to model the house price data, we assume there exists noise in both our model (epistemic uncertainty) and data (aleatoric uncertainty), so that where
prices are normally distributed conditioned on seeing the features with the mean being the equation of the line where , then we have that
Returning to the linear regression model, we can solve this optimization with a direct method using normal equations. QR factorization, or SVD.
def fhatbatched(X_n: np.ndarray, m: float, b: float) -> np.ndarray: return X_n*m+b
if __name__ == "__main__":
X, Y = np.array([1500, 2100, 800]), np.array([500000, 800000, 250000]) # data
X_b = np.column_stack((np.ones_like(X), X)) # [1, x]
bhat, mhat = np.linalg.solve(X_b.T @ X_b, X_b.T @ Y) # w = [b, m]
yhats = fhatbatched(X, mhat, bhat) # yhat
for y, yhat in zip(Y, yhats):
print(f"expected: ${y:.0f}, actual: ${yhat:.2f}")
To summarize, we have selected and computed
- an inductive bias with the family of linear functions
- an inductive principle with the least squared loss
- the parameters which minimze the empirical risk, denoted as
Together, the inductive bias describes the relationship between the input and output spaces, the inductive principle is the loss function that measures prediction accuracy, and the minimization of the empirical risk finds the parameters for the best predictor.

Intermezzo One: The Language of Dimension and Stochasticity
While I am trying to be as precise as possible, I chose to use English rather than formal specifications for everything except syntax and lexical analysis. This should make the document more understandable to the average reader, but will leave room for ambiguities. Consequently, if you were coming from Mars and tried to re-implement Python from this document alone, you might have to guess things and in fact you would probably end up implementing quite a different language.
Guido van Rossum, The Python Language Reference
But then, Nicias, courage, according to this new definition of yours, instead of being a part of virtue only, will be all virtue?
Socrates, in Plato’s Laches
There are good reasons why the theorems should all be easy and the definitions hard. As the evolution of Stoke’s Theorem revealed, a single simple principle can masquerade as several difficult results; the proofs of many theorems involve merely stripping away the disguise. The definitions, on the other hand, serve a twofold purpose: they are rigorous replacements for vage notions and machinery for elegant proofs.
Michael Spivak, Calculus on Manifolds
Fixed-Size DataA modified excerpt from How to Design Programs (Felleisen et al. 2018)’s Intermezzo 1: Beginning Student Language §1. Self-Supervised Sequence Learning with Single-Layer Networks deals withBSLthe NETSOR language implemented byborscht.Tensoras if it were a natural language. It introduces the “basic words” of the language, suggests how to compose “words” into “sentences,” and appeals to your knowledge of algebra for an intuitive understanding of these “sentences.” While this kind of introduction works to some extent, truly effective communication requires some formal study.
I1.1 From Computation to Mathematics
All throughout §1. Self-Supervised Sequence Learning with Single Layer Networks, we defined various functions
which called models to approximate nature’s true mapping of the input space of sentences to the outspace of tokens.
The first bigram model counted probabilities with a stochastic matrix,
and the second perceptron model discriminated with a weighted sum, but also inferred the distribution by composing the weighted sum with a squashing function.
Although these stochastic and infinitely continuous functions are not the usual deterministic and discretely finite ones we’re used to implementing — with assigning bindings, controlling the flow with if statements and loops, printing to standard output —
as programmers we are comfortable with the notion of function, whether you specify one with fn, fun, function, or of course our dear friend def.
But if someone who was not as well-versed as you asks for the definition of the function as a concept itself, what is your answer?
Can we say that a function is a rule that maps one input to a unique output?
Up until the 1900s, this was an acceptable definition!
For bettter and for worse (for us programmers, it’s often the latter), the mathematicians of the 20th century wanted a language that was more precise.
Take for example the familiar quadratic from high school — it’s much simpler than a bigram’s stochastic matrix or a perceptron’s weighted sum.
We can write down the algebraic equation of the quadratic with pen and paper (or rather ( code \LaTeX)),
computational code with 
borscht, and also it’s corresponding geometric curve with a plot
import torch
import matplotlib.pyplot as plt
%matplotlib inline
plt.rcParams["figure.figsize"] = (4, 3)
def f(x: torch.Tensor):
return x ** 2
x = torch.linspace(-3, 3, 200)
y = f(x)
plt.plot(x, y)
[<matplotlib.lines.Line2D at 0x117619400>]
To any mathematician whom lived before the 20th century, they would tell you that the quadratic is indeed a function. If you asked them why, they would respond, well, because it’s a rule that passes the vertical line test — there is a unique output for every input.
If you ask any mathematican who comes after, they would and will tell you that all of representations above describe a function — namely our long time friend the quadratic — and that the vertical line test indeed gets to the notion of the concept, but it does not precisely define the concept of function. To illustrate this, they would take the questioning one level further, and ask, well what does “rule” mean, and what does “unique” mean? (Notice how those two words have quotes now!) They are not satisfied by defining the concept of a function with a machine and pencil.
As programmers learning mathematics, we can view this transition of mathematics similar to different styles or paradigms of programming Even though those terms in of themselves are non-sensical!. The same way post 20th century mathematicians are uncomfortable defining the concept of a function with vague notions of a machine and pencil, functional programmers are uncomfortable evaluating expressions with mutation, objective oriented programmers on the other handA false dichotomy but useful one for our current purposes. For instance, Python has lambdas and classes, so what category is it considered as? Go take a programming language course to find out! We recommend the excellent https://www.plai.org/ can’t live without it. These two groups of mathematicians are describing the same objects, and the two groups of programmers are evalauting the same objects. They simply have different relationships to them. Specifically for mathematics, the post 20th century mathematician arguably has a deeper relationship with the notion of function. So, if they are uncomfortable defining the concept of a function with a machine and pencil, what do they want to define it with?
We know that good artists copy, while great artists steal. And these 20th century mathematicians were inspired by an Ancient Greek mathematician named Euclid who lived around two milleniums ago, who did to geometry what they were hoping to do for functions.
Start with some assumptions called axioms.
Define mathematical objects using a definitional language, precisely describing the what.
State beleifs about such objects,
and prove them using an assertion language, precisely describing the why.
The goal of this intermezzo is to do the same, formalizing the notions and beliefs of the mathematical objects we’ve been using all throughout §1. Self-Supervised Sequence Learning with Single Layer Networks. While we start with functions in §I1.1, Relations and Functions, we will be doing the same for the linear transformations of vector spaces over fields in §I1.2 Vector Spaces and Linear Maps, as well as probability distributions of random variables from probability spaces in §I1.3 Probability Spaces and Distributions.
This is going to be very frustrating for us programmers!
We are already familiar with the data structures and learning algorithms of such objects!
We know that a discriminative perceptron is a weighted sum that linearly separates space.
We know that assuming bernouilli results in the cross-entropy loss.
We know that batching them results in a linear system of equations that we can directly solve.
We know that a probabilistically discrminative perceptron squashes the weighted sum into a probability.
What’s the purpose of defining their definitions and proving their properties?
To transfer our understandingThe preface provides more motivation..
Before we deepen our relationship,
with the dimensionality provided by linear transformations of vector spaces
and the stochasticity provided by probability distributions of probability spaces,
let us first understand the language we use to describe these languages.
Deeper the rabbit hole we go.
I1.1.1 Sets and Built-in Numbers
Table of Contents (I1.1 From Computation to Mathematics)
No one shall expel us from the paradise which Cantor has created for us.
David Hilbert
Mathematics and computation have a close relationship with one another. As programmers, we naturally gravitate towards the mathematical languages that overlap with the discrete algorithms and data structures we program on a daily basis. Graph theory is the prime example, where we can model relationships between objects with a graph , which is defined as a set of vertices , and a set of unordered edges that connect them.
A subtle thing just happened with the previous sentence.
We provided a general definition of a mathematical object,
and you most likely imagined a graph of concrete objects. Perhaps you thought of a graph of web pages like a search engine,
a graph of people like a social network, or even a graph of autonomous vehicles.
Whatever the case may be, the concept of a graph was genericallyIf you are familiar with parametric polymorphism such as generics, it is quite similar. defined by using the notion of a set, which is an unordered collection of elements.
As programmers we nod along with passing familiarity. Indeed, all programming languages provide sets.
In fact with Python all that is needed is set(). From the Python tutorial’s 5.4 Sets,
we can construct a set of fruits with {'apple', 'orange', 'apple', 'pear', 'orange', 'banana'}In Python, set() expect’s at most 1 argument, and {} by itself creates an empty dictionary!.
A future update to this book will use type theoretic foundations with Lean.
Another subtle thing also just happened with the previous sentence.
The mathematician’s relationship with the set is to define or produce other mathematical objects.
The programmer’s relationship with the set is to use it model real world objects.
…
The more accurate analogy for the mathematician’s set is the programmer’s type.
As a programmer, if you were asked to define a graph data structure,
you would use a programming language’s type system.
This is why mathematics begins with set theory.A future update to this book will use type theoretic foundations with Lean. The hacker’s paradise.
Up ahead, we will informally present the notion of sets and fields, in order to formalize the concept of real-valued functions. We will then be ready to formalize linear transformations on n-dimensional Euclidean spaces in I1.2 Vector Spaces and Linear Maps. That is, we will first define what precisely means so that we can do the same with .
Before we formalize the notion of a mapping from inputs and outputs into a precise concept with sets, we need to formalize the notion of sets themselves.
A setset is an unordered collection of objects.
Although we can have sets of any object’s we like — fruits as one timely example — the primary sets we concern ourselves with are sets of numbers since the functions of software 2.0 are of numbers.
To construct a set, you enclose it’s elements with curly braces ( code \{\}).
Mathematicians, like programmers, are lazy. Sometimes it’s too tedious to specify every element of a set,
and most elements are ellided with an ellipsis ( code \dots).
For instance, the set of all numbers from 1 to 10 is constructed as
Finally, we may have a set of infinite numbers, such as natural numbers, integers, rationals, real numbers, and the complex ones. For such infinite sets, we place the ellipsis at the beginning or end. Note how the ellipsis placed at beginning or end of a set has a very different meaning than one used to elide elements in the middle.
import torch
print(torch.int64)
print(torch.float64)
print(torch.complex64)
torch.int64 torch.float64 torch.complex64
Computationally speaking, borscht aspirationally approximates with borscht.int64, with borscht.float64
and with borscht.complex64. The finite precision representations of these sets have quite different definitions than their infinite counterparts because the unbounded elipses is quite problematic with finite memory.
We’ll cover more of these differences when we implement borscht itself in 2. The Massively Parallel Multidimensional Tensor.
For the time being, keep in mind this slight abuse of contrast between the mathematics and computation.
The way borscht.dtype’s are used in practice is to specify that certain computations are carried out in certain types of precision.
We can generate numbers with borscht’s random sampling methods, and verify their dtype with Tensor.dtype.
import torch
some_ints = torch.randint(-10, 11, (3,))
some_reals = torch.rand(3, dtype=torch.float64)*20-10
print(some_ints, some_ints.dtype)
print(some_reals, some_reals.dtype)
tensor([9, 0, 4]) torch.int64 tensor([-5.1043, 4.5641, 9.7501], dtype=torch.float64) torch.float64
- To generate a list of three random integers , we call
borscht.randint(low, high, size)where low is the lowest int to be drawn inclusively, high is exclusively one above the highest int, and size is a tuple for the shape of the output Tensor which is used as a list. - To do the same with real numbers , we call
borscht.rand(size)which produces a tensor of random numbers limited to the range where size is the shape int… of the output Tensor. We broadcast multiply by to get a range and broadcast subtract by to shift it over to .
The entire purpose of the concept of a set is to distinguish whether objects are elements or are not elements of the set.
A setset is a collection of unordered objects from a universe.
The collection of everything in the universe is called the universal set denoted by ( code: \mathscr{U})
For some set , the objects are called elements and denoted by ( code: x \in X).
The productproduct of two sets and is the set
denoted by ( code: X \times Y).
For a given element (x,y) of the set product, we say that is the x-coordinate and is the y-coordinate.
I1.1.2 Relations and Functions
Table of Contents (I1.1 From Computation to Mathematics)
Although the formal concepts of sets and the real numbers are precisely defined in Appendix A. Numbers, we now better understand their notions as unordered collection of objects and an ordered field with the usual operations of addition, substraction, multiplication, and division well-defined. We have one more notion to formalize before we can define what a function precisely means. That notion is the one of relation.
A relationrelation on sets and is a subset of their product
For some relation ,
we say that the set is the domain of R,
and that the set is the range of R.
For instance, let us consider the relation
import torch
import matplotlib.pyplot as plt
%matplotlib inline
plt.rcParams["figure.figsize"] = (4, 3)
def f(x: torch.Tensor):
return x.sqrt()
x = torch.linspace(-3, 3, 200)
y = f(x)
plt.plot(x, y)
plt.plot(x, -y)
[<matplotlib.lines.Line2D at 0x117908590>]
That was quite some work. And for what? Just to have precise and well-defined concept of a real-valued function
which we have already used all throughout §1. Self-Supervised Sequence Learning with Single Layer Networks? For better and for worse, yes!
Now that the concept of functions are well-defined, when we represent a function algebraically with pen and paper (or rather ( code \LaTeX)), computationally with code, or geometrically with it’s plot
import torch
import matplotlib.pyplot as plt
%matplotlib inline
plt.rcParams["figure.figsize"] = (4, 3)
def f(x: torch.Tensor):
return x ** 2
x = torch.linspace(-3, 3, 200)
y = f(x)
plt.plot(x, y)
[<matplotlib.lines.Line2D at 0x117619400>]
you understand the conecpt of a function more generally, and precisely. A function is a subset of a product such that ___. Now that we have formalized the concept of real-valued functions, let us move on and formalize linear transformations on n-dimensional Euclidean spaces.
I1.1.3 Field of Scalars, and List of Coordinates
Table of Contents (I1.1 From Computation to Mathematics)
I1.2 Vector Spaces and Linear Maps
Table of Contents (Intermezzo One)
Unfortunately, no one can be told what the Matrix is. You have to see it for yourself.
Morpheus
The introduction of numbers as coordinates is an art of violence.
Herman Weyl
I1.2.1 Vector Space over Fields
We are now ready to abstract what is common in both the computational perspective of vectors as lists and the geometrical perspective of vectors as points. Such generalization is viewing vectors as objects with operations of addition and scalar multiplication.
Table of Contents (Intermezzo One)
Abstract vector spaces | Chapter 16, Essence of linear algebra (3Blue1Brown 2016)
In the video, 3Blue1Brown mentions that a vector is any mathematical object
A d-length listlist for some is an ordered collection of elements, denoted by with code (x_1, \dots, x_d).
For two lists and , we say they are equal if and only if and . That is, if they are the same length whose elements agree at every position.
A d-dimensional Euclidean spaceEuclidean space for some is the set
denoted by with code: \mathbb{F}^d
and it’s elements by with code: \mathbf{x}.
For some and , we say that is the ith coordinate of .
A vector spacevector space
missing Lean module: SitpLean.Vectorspace
A subspacesubspace is a
Summary of newly introduced Lean constructs
I1.2.2 Combinations and Bases
Table of Contents (Intermezzo 1.2 Vector Spaces and Linear Maps)
Spans, linear combination, linear dependance, bases
A linear combinationlinear combination of a list of vectors is a vector of the form
where .
The spanspan of a list of vectors is the set of all their linear combinations:
and is denoted by
If , we say that the list spans .
I1.2.3 Linear Maps and Matrices
Table of Contents (Intermezzo 1.2 Vector Spaces and Linear Maps)
Matrices are linear maps
A linear maplinear map from vector space to is a function where satisfies
additivity:
homogeneity:
The null spacenull space of a linear map is the subset of
denoted by
The column spacecolumn space of a linear map is the subset of
denoted by
I1.3 Probability Spaces and Distributions
Table of Contents (Intermezzo One)
In which we
I1.3.1 Probability Spaces
Informally speaking, you intuitively understand the notion of a probability and a probability distribution. That is, a probability is a number between 0 and 1 that represents the chance, likelihood, or belief of some event happening, and a distribution is a list of such numbers. Probability distributions come from the concept of probability spaces, so before defining the former we must define the latter, which are measures of the size of sets.
Figure I.1
Figure I.1 is comprised of three components. Namely, the blue box and it’s blue dots, the colored boxes, and the mapping from the colored boxes to the number line
- the sample space : the set of all possible outcomes in an experiment
- the event space : the set of all subsets in the sample space
- probability law : a mapping from the event space to a number between 0 and 1
Conceptually speaking, the language models we’ve seen in §1. Sequence Learning
such as GPT-2, the bigram model, and the logistic regression model which have type
produce (on a single evaluation) concrete probability distributions with 
borscht.Tensor
that come from
mathematically abstract probability spaces
where the sample space is the set of words in their vocabulary,
and their belief in a word occuring is represented by probability law defined on event space .
Caution
Probability spaces are usually NOT implemented with code. For instance, GPT-2, bigram models, and logistic regression models do NOT instantiate and return Python
set()s for the sample space , the event space , nor define some functiondef probability_law(event_space: set) -> floatfor . They return a probability distributions, which we define in §I.2 Random Variables and their Distributions. Rather, the purpose of probability spaces as a mathematical definition is conceptual clarity with general definitionsDefining concepts without commiting to internal structure allows for a much broader class of objects to be considered. This is very much like type polymorphism (generics, traits, type classes), except the generality of mathematical concepts are usually “more” general than those that might be implemented with type polymorphism. For instance, this abstract definition of a probability space is generalized to cover any random experiment we’d like to model, from rolling dice, to generating language. However it’s quite unusual to have a program need to progam both. and rigorous results with theorems.
Let us now precisely define each component of a probability space , starting with the sample space .
A sample spacesample space is the set of all possible outcomes in an experiment denoted by ( code: \Omega).
- Consider flipping a coin. Then .
- Consider playing rock, paper, scissors. Then .
- Consider rolling a die. Then .
- Consider drawing a card. Then .
Consider generating a word from this very sentence. Then,
So conceptually speaking, language models like GPT2, the bigram model, and the logistic regression model
of type
which produce concrete distributions with 
borscht.Tensor on a single evaluation come from
mathematically abstract probability spaces where the sample space is the set of words in their vocabulary,
and their belief in a word occuring is represented by probability law defined on event space .
Important
Notice how we have NOT said anything about multiple evaluations of a language model’s output distribution (and probability space) over words to generate a complete sentence (in the probablity space of sentences). We will see how they relate soon.
An event spaceevent space is the set of all possible subsets of the sample space denoted by ( code: \mathcal{F}).
TODO: something to say here about event spaces? cardinality of them?
Consider flipping a coin. Then we model the experiment with sample space , event space , and probability law
[!QUESTION]
Why is the probability law defined as a function on the event space rather than the sample space ? Pause, think! Hint: Would something bad happen if we did such a thing?
A probability lawprobability law is a function that sends an event to a number between 0 and 1, satisfying three axioms:
- non-negativity:
- normalization:
- additivity:
However the event which contains all foobarbaz is composed of overlapping events (corresponding to the right figure above), so the axiom of addivitiy does not apply. We can evaluate the union of non-disjoint (overlapping) events with the following corollary, known as the sum rulesum rule:
We have now sharpened our intuitive notion of chance, belief, and probability by formally defining them as the measure of the size of sets. These sets have a home, and the measure has a domain, namely the sample space and the event space and together with the probability law , comprise the definition of a probability space , albeit abstractly so. Let us now connnect this mathematically abstract space to the more concrete probability distributions we used in §1. Sequence Learning.
I1.3.2 Random Variables and their Distributions
Table of Contents (Intermezzo One)
Probability distributions are the practical and concrete 
borscht.Tensors we used throughout §1. Sequence Learning
when implementing the bigram and logistic regression language models.
So how do these connect to the abstract concept of probability spaces just defined?
The answer is with random variables and their probability mass functions,
which are functions that map outcomes to states, and from states to probabilities.
Figure I.2
(todo modify picture or include another picture to show distribution (probality assignment of variable taking on state))
Figure I.2 shows the same sample space and event space from §I.1 Probability Spaces, but rather than evaluate probabilities with the probability law by mapping events to the [0,1] number line (displayed in Figure I.1), we create one level of indirection with
- a random variable mapping outcomes in the sample space to states on the real number line and then defining
- a probability mass function mapping states to their probabilities.
So, starting with a sample space, you have to map outcomes to their state space with a random variable
before you can evaluate their probabilities with a probability mass function, rather than evaluate them directly with a probability law.
In other words, the two types are not equivalent .
For example, recall from §1.1 From Certain to Uncertain Knowledge
GPT-2’s 
borscht.Tensor output probs when passed the input string "Hello world, nice to":
import numpy as np
# GPT-2's output distribution when passed input "Hello world, nice to"
# Showing top 10 most likely words out of 50257.
tokens = [" see", " meet", " hear", " have", " be", " know", " you", " talk", " say", " get"]
probs = np.array([0.3169, 0.1268, 0.1246, 0.1046, 0.0471, 0.0352, 0.0320, 0.0128, 0.0114, 0.0067])
for i in np.argsort(-probs):
print(f"{tokens[i]} : {probs[i]:.4f}")
Here, there is a random variable which maps outcomes from the abstract sample space to concrete states in state space . That is,
But notice the level of indirection with the random variable. All we’ve done is translate from sample space to state space. To obtain actual probabilities, we need to map from the state space to probabilities with the probability mass function . That is,
In other words, starting from sample space we compose with to obtain such that evaluating results in the probability of outcome . (todo. this is wrong?)
But how is the multidimensional array probs with support in related to the abstract concept of a probability space?
You may have noticed that the definition of looks quite similar to that of the probs:
import numpy as np
probs = np.array([0.3169, 0.1268, 0.1246, 0.1046, 0.0471, 0.0352, 0.0320, 0.0128, 0.0114, 0.0067])
This is because an alternative conceptualization of is some function
For conveniece sake we do store the outcomes in the tokens array so that we can print them next to their probabilities by looping over states with for i in np.argsort(-probs), but it’s important to note that is defined on real numbered states (represented with indices), and not on set-valued events. that has support in , or more accurately, With these two views, an alternative conceptualization of is some function
- todo: rvs map from one measurable space to another. we happen to like
On a single evaluation language models produce such probability mass functions
defined on a state space which are mapped over from an abstract sample space via random variable.
In practice, we forget about the concepts of sample spaces and random variables,
simply slugging around probability mass functions with 
borscht.Tensors that have support in
n-dimensional Euclidean space, where n is the size of sample space.
In other words, we keep two as conceptual mathematical definitions without implementing them with code.
At this point, you may be wondering what is the point of the random variable’s indirection? … Now that you understand the notion of random variables and probability mass functions intuitively, let’s formally define them as concepts:
somehow motivate global view
language modelslanguage models
The intuition is very simple: every language model can be locally normalized. The precise formulation however, is not. proof with prefix probabilities
proof
however the converse direction is not a trivial result to establish examples
- 2 state model (markov chain), non-tight
- 2 state model (markov chain), tight (absorbs)
I1.3.3 Joint, Marginal, Conditional, and Bayes Rule
Table of Contents (Intermezzo One)

La Perspective practique (Jean Du Breuil 1642)
2. The Massively Parallel Multidimensional Tensor
2.1 Virtualizing Shapes with Strides
(todo, cover other subchapters). In which we introduce the so-called three language problem that deep learning frameworks suffer from, which introduce accelerated languages for the host and device with Rust and CUDA Rust, introduce the Rust programming language, and finally, set up a mixed source project of Python and Rust for
borschtwith Rust bindings for Python viaPyO3and it’s build toolmaturin.
Table of Contents (§2. The Massively Parallel Multidimensional Tensor)
2.1.1 From Virtual to Physical Machines
Table of Contents (§2.1 Virtualizing Shapes with Strides)
The Three Language ProblemThree Language Problem is the colloquial way of describing the fact that many deep learning frameworks are implemented with three different programming languages. This multi-language implementation for a single system actually has precedent in scientific computing before the advent of deep learning. Traditionally, scientists of the natural such as physicists, chemists, biologists or psychologists who needed to perform numerical computation reached for frameworks and languages such as MATLAB, R, numpy, and julia
that have a high levelhigh levelSemantics… veneer-like language for productivity
with a low levellow level internal implementation language for performance.
Roughly speaking, such high level languages refer to those such as Python that are closer to the English language which humans speak, whereas low level languages refer to those such as Fortran, C++ or Rust that are closer to the machine languages that electricity “speaks”. The process of computation, at the end of the day, is to bridge such a semantic chasm through the orchestration of electrons.
Figure X.Y.Z
Really, the distinction of high level vs low level programming languages is a non-sensical category error, for what this distinction is really trying to gesture at is whether a programming language’s implementation is interpretedinterpreted or compiledcompiled — in other words, whether it’s evaluatedevaluated or translatedtranslated. The difference between an evaluator and a translator is in their return value. For instance, consider a language that can only perform arithmetic on numbers. Then an evaluator returns the actual value of the number, whereas the compiler returns a translation of program from the sourcesource language to a targettarget language.
def interpret_python(python_prg: str) -> num
raise NotImplementedError("my awesome python interpreter")
def compile_python(python_prg: str) -> str
raise NotImplementedError("my awesome python compiler")
However, even compilers exist for higher-level languages such as the compiler that translates Typescript to Javascript, which is then evaluated by a Javascript virtual machine living in the browser you are using to read these very words.
In fact, interpreters exist for lower-level languages! Rust has a user-land interpreter called evcxr, and an internal interpreter to the rustc compiler. So we must refine such distinctions one more.
What we are truly after is a language whose primary implementation is a compiler which targets a low-level machine languge executed by a physical processorphysical processorIn the sciences artificial, all distinctions are usually falsely dichotomous and are moreso spectrums. Languages such as Java, Javascript, and soon to be Python can by default be interpreted by a virtual machine, and then compiled just-in-time to a physical machine. The Python JIT and Javascript previous JITs are largely influenced by the work of Cliff Click on the Java JIT.. And because the primary implementation of the Rust programming language is the compiler rustc which targets architectures such as x86, ARM, and RISC-V.
Such languages are referred to as nativenative
because the translated program is in the machine’s native language.
So with everything said and done, we will be implementing 
borscht with a veneer of productive Python code which is bound to accelerated Rust code.If you want to follow along with a pure Haskell implementation, go for it.
Let us take charge of our own education, as ultimately we are the captain of our own ship.
Starting with Rust, although it’s similar to Python in that is has support for structured programmingSee Structured Programming (Dahl et al. 1972). with control flow and functions, in contrast to Python, Rust is a statically typedstatically typed programming language in that it has variables whose types must be known at compile time before the program is executed. What differentiates Rust from other languages though is it’s unique combination of two language features that change the way we program data: ownershipownership inspired by systems programmingsystems programming and algebraic data typesalgebraic data types inspired by functional programmingfunctional programming. We will explain such language features in their use up ahead (and also make comparisons to C++, the lingua franca of systems programming). If you’d like to revisit Rust from the ground up, please consult Brown’s fork of The Rust Programming LanguageSee Will Crichton: Rust for Everyone! , specifically Chapter 4 for ownership, and Chapter 5 with Chapter 6 for algebraic data types.
First, get yourself an installation of the Rust toolchaintoolchain
specific to your operating system architecture with the command line tool rustuprustup, which installs the Rust programming language from the official release channelsSee The Rust Book Appendix G, and The rustup Book §2.1 Although non-technical, this more social aspect of continually evolving the Rust programming language is a unique differentiator from C/C++.. The Rust programming language provides excellent documentation for it’s tooling — the rustup docs explain that:
rustupis a toolchain multiplexer. It installs and manages many Rust toolchains and presents them all through a single set of tools installed to~/.cargo/bin. Therustcandcargoexecutables installed in~/.cargo/binare proxies that delegate to the real toolchain.rustupthen provides mechanisms to easily change the active toolchain by reconfiguring the behavior of the proxies.
So whenrustupis first installed, runningrustcwill run the proxy in$HOME/.cargo/bin/rustc, which in turn will run the stable compiler. If you later change the default toolchain to nightly withrustup default nightly, then that same proxy will run thenightlycompiler instead.
This is similar to Ruby’s rbenv, Python’s pyenv, or Node’s nvm.
Such a toolchain includes several components: the core ones being standard librarystandard library stdstd, the reference compilercompiler rustcrustc, and the package managerpackage manager cargocargo to drive rustc on your behalf. There are some other tools like clippy and rustfmt, and rust-docs, which we will use in due time.
However, rather than get the standard hello world program up and running with Rust,
recall that 
borscht requires a mixed source of Rust and Python.
Luckily for framework developers in numerical and scientific computing, the Python language is aware of how crucial natively accelerating mixed source software, that there is official support to extend the Python’s reference interpreter CPython with native C/C++ code. In fact, many of Python’s standard library modules are implemented themselves in C and C++.
Alternatively pybind, and nanobind, which are used by tensorflow and torch.
We will be doing everything with the help of PyO3,
a Rust package that provides Rust bindings to the Python’s reference interpreter CPython via it’s C API.
Driven by the build tool maturin, the development process compiles Rust into a dynamic librarydynamic library with the C ABI (expand) which CPython can import as a native extension modulenative extension module (expand).
While we can create such a mixed source project manually, let us use the build tool maturin. First install maturin (expand). Now, although our agents read most of our man pages on behalf of us these days, let us read it’s man pages. Usually, most command line tools provide their man pages by passing in some variant of -h or --help command line flags. Let’s try maturin -h:
$ maturin -h
Build and publish crates with pyo3, cffi and uniffi bindings as well as rust binaries as
python packages
Usage: maturin [OPTIONS] <COMMAND>
Commands:
build Build the crate into python packages
publish Build and publish the crate as python packages to pypi
list-python Search and list the available python installations
develop Install the crate as module in the current virtualenv
sdist Build only a source distribution (sdist) without compiling
init Create a new cargo project in an existing directory
new Create a new cargo project
generate-ci Generate CI configuration
upload Upload python packages to pypi
generate-stubs Autogenerate type stubs
help Print this message or the help of the given subcommand(s)
Options:
-v, --verbose... Use verbose output
-h, --help Print help (see more with '--help')
-V, --version Print version
Visit https://maturin.rs to learn more about maturin.
(expand, what’s pyo3, cffi, and uniffi bindings).
We even see that it points us to it’s web documentation https://maturin.rs, which you should definitely consult in addition to our journey (change this word) here. What we want is to create a new project, without any existing directory, so maturin new seems like the command we want.
The Usage: line above the list of Commands: tells us how we can invoke commands with maturin, namely with maturin [OPTIONS] <COMMAND>. They also conveniently list Options below, and we see that -h is in fact a flag on commands themselves.
So, let us recurse and check out the command’s man pages with maturin new -h.
$ maturin new -h
Create a new cargo project
Usage: maturin new [OPTIONS] <PATH>
Arguments:
<PATH> Project path
Options:
--name <NAME> Set the resulting package name, defaults to the
directory name
-v, --verbose... Use verbose output
--mixed Use mixed Rust/Python project layout
--src Use Python first src layout for mixed Rust/Python
project
-b, --bindings <BINDINGS> Which kind of bindings to use [possible values:
pyo3, cffi, uniffi, bin]
-h, --help Print help (see more with '--help')
The --mixed flag is what we need (justify). Let us enter the command now, following the provided Usage and passing in the <PATH> name of the our project directory: maturin new --mixed borscht/, in which we will be prompted with what kind of bindings to use. Select the pyo3 option (justify).
$ maturin new --mixed borscht
? 🤷 Which kind of bindings to use?
📖 Documentation: https://maturin.rs/bindings.html ›
❯ pyo3
cffi
uniffi
$ maturin new --mixed borscht
✔ 🤷 Which kind of bindings to use?
📖 Documentation: https://maturin.rs/bindings.html · pyo3
✨ Done! New project created borscht
If we cd into borscht and ls with the -l flag we see two configuration files with pyproject.toml and Cargo.toml, as well as two directories of Python and Rust source python/ and src/:
$ cd borscht && ls -l
Cargo.toml
pyproject.toml
python
src
Let us open the two configuration files of pyproject.toml and Cargo.toml side by side in our favorite code editor:
[build-system]
requires = ["maturin>=1.14,<2.0"]
build-backend = "maturin"
[project]
name = "borscht"
requires-python = ">=3.8"
classifiers = [
"Programming Language :: Rust",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
dynamic = ["version"]
[project.optional-dependencies]
tests = [
"pytest",
]
[tool.maturin]
python-source = "python"
exclude = [
"**/__pycache__",
"**/*.pyc",
"**/*.pyo"
]
[package]
name = "borscht"
version = "0.1.0"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "borscht"
crate-type = ["cdylib"]
[dependencies]
pyo3 = "0.29.0"
with the [name] [version] and [edition] [package] fields,
the [name] and [crate-type] [lib] fields.
Opening borscht/python/borscht/__init__.py and borscht/src/lib.rs we see the following:
from .borscht import *
__doc__ = borscht.__doc__
if hasattr(borscht, "__all__"):
__all__ = borscht.__all__
#![allow(unused)]
fn main() {
use pyo3::prelude::*;
/// A Python module implemented in Rust.
#[pymodule]
mod borscht {
use pyo3::prelude::*;
/// Formats the sum of two numbers as string.
#[pyfunction]
fn sum_as_string(a: usize, b: usize) -> PyResult<String> {
Ok((a + b).to_string())
}
}
}
with maturin develop:
$ uv venv
Using CPython 3.14.7 interpreter at: /opt/homebrew/opt/python@3.14/bin/python3.14
Creating virtual environment at: .venv
Activate with: source .venv/bin/activate
$ source .venv/bin/activate
(borscht) $
(borscht) $ maturin develop
Updating crates.io index
Locking 14 packages to latest Rust 1.92.0-nightly compatible versions
🍹 Building a mixed python/rust project
🐍 Found CPython 3.14 at /borscht/.venv/bin/python
🔗 Found pyo3 bindings
Audited 1 package in 49ms
Compiling target-lexicon v0.13.5
Compiling proc-macro2 v1.0.107
Compiling quote v1.0.47
Compiling unicode-ident v1.0.24
Compiling libc v0.2.189
Compiling heck v0.5.0
Compiling once_cell v1.21.4
Compiling pyo3-build-config v0.29.2
Compiling syn v2.0.119
Compiling pyo3-ffi v0.29.2
Compiling pyo3 v0.29.2
Compiling pyo3-macros-backend v0.29.2
Compiling pyo3-macros v0.29.2
Compiling borscht v0.1.0 (/borscht)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.92s
📦 Built wheel for CPython 3.14 to /var/folders/wh/m0ckr3n10fjbfzq08k3j0hlr0000gn/T/.tmpO6X6Ul/borscht-0.1.0-cp314-cp314-macosx_11_0_arm64.whl
✏️ Setting installed package as editable
🛠 Installed borscht-0.1.0
(borscht) $ uv pip list
Package Version Editable project location
------- ------- ----------------------------------
borscht 0.1.0 ~/borscht
(borscht) $ uv pip list
Python 3.14.7 (main, Aug 5 2026, 10:29:49) [Clang 21.0.0 (clang-2100.1.1.101)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import borscht
>>> borscht.sum_as_string(40, 2)
'42'
>>>
We now have an honest to god mixed project of Python and Rust for 
borscht.
If you ever need to create a new project for a different use case, you now understand how the pyo3 bindings and maturin build tool work and can simply invoke maturin new --mixed <PATH>, or tell your favorite agent to do it on your behalf.
Let’s now discuss how we should architect such a deep learning framework.
2.1.2 The Architecture of a Deep Learning Framework
Table of Contents (§2.1 Virtualizing Shapes with Strides)
Deep learning frameworks suffer from the three language problem, and in 2.1.1 From Virtual to Physical Machinesyou were introduced to 
borscht’s first two implementation languages of Python and Rust. Because of Python’s reference interpreter CPython supports extension modules with the C API (expand), we were able to use Rust’s FFI ability (expand) and PyO3 to create a native module which our Python package’s init.py imported. We were left with maturin’s boilerplate of borscht.sum_as_string(), which doesn’t seem too helpful in our journey towards building ourselves an autograd from scratch.
Let us now discuss how we should architect such a framework.
- Layer -1: Multidimensional Array
- Layer 0: Performance Primitives
- Layer 1: Low-level Mathematical Routines
- Layer 2: High-level Mathematical Routines
2.1.3 Implementing Layer -1: Strides, Views and Indexing
Table of Contents (§2.1 Virtualizing Shapes with Strides)
A potential compromise here is native code components should be done modularly, but always using CPython as the encapsulation boundary. Optimized components are always implemented native Python and with C++. Impending free-threaded Python eliminates multithreading problem.
Deep learning frameworks suffer from the three language problem, and are architected into several layers.
Cornell CS 5781 Machine Learning Engineering Lecture 2.1 Tensors
#![allow(unused)]
fn main() {
enum Device {
Cpu,
Gpu
}
enum Dtype {
Float64(f64),
Float32(f32),
Float16(f16),
Bfloat16(bf16),
}
struct Tensor {
shape: Vec<u8>,
stride: Vec<u8>,
device: Device,
dtype: Dtype,
storage: Vec<Dtype>
}
}
The core idea behind the borrow checker is that variables have three kinds of permissions on their data:
- Read (R): data can be copied to another location.
- Write (W): data can be mutated in-place.
- Own (O): data can be moved or dropped.
2.1.4 Implementing Layer 0: Transforms, Reductions, and Broadcasts
Table of Contents (§2.1 Virtualizing Shapes with Strides)
Cornell CS 5781 Machine Learning Engineering Lecture 2.2 Tensor Functions
Cornell CS 5781 Machine Learning Engineering Lecture 2.3 Shapes
2.2 Accelerating Basic Linear Algebra on CPUs
Table of Contents (§2. The Massively Parallel Multidimensional Tensor)
In which we …
2.2.1 From rust to risc-v
Table of Contents (§2.2 Accelerating Matrix Multiplication on CPUs)
2.2.2 Instruction Sets, Pipelines, and Hierarchies
Table of Contents (§2.2 Accelerating Matrix Multiplication on CPUs)
2.2.3 Measuring Bottlenecks to Throughput with Roofline Plots
Table of Contents (§2.2 Accelerating Matrix Multiplication on CPUs)
2.2.4 Accelerating saxpy on risc-v
Table of Contents (§2.2 Accelerating Matrix Multiplication on CPUs)
2.2.5 Accelerating sgemv on risc-v
Table of Contents (§2.2 Accelerating Matrix Multiplication on CPUs)
2.2.6 Accelerating sgemm on risc-v
Table of Contents (§2.2 Accelerating Matrix Multiplication on CPUs)
Loop Reordering, Register and Cache Blocking
Instruction Level Parallelism via Loop Unrolling
2.3 From Performance Primitives to Linear Solvers
2.4 Accelerating Solvers with Decompositions
Plato’s Cave (Jan Pietersz Saenredam after Cornelis Cornelisz van Haarlem 1604)
You are viewing this on a mobile device, but SITP is best viewed on a desktop — the book includes various multimedia lecture videos, visualizers, any tufte-style sidenotes with many external hyperlinks to other resources.
II. Neural Networks
Deep Neural Networks
Welcome young hacker! Glad to see you here. The whimsical whirlwind tour known as The Structure and Interpretation of Tensor Programs (SITP) started with Part I. Elements of Networks where we developed a solid breadth of foundation in the computational, mathematical, and performance-oriented perspectives of the statistical approach to machine learning. By now, although we’ve “only” trained what amounts to a single layer network, we are now more than comfortable in the stochastic and infinitely continuous language of software 2.0. That is, although using the precise tools of mathematics to specify architectures, loss functions, and optimizers, the essence of the discipline is more akin to synthetic chemistry or microbiology which grow chemicals and cells. In Part II. Neural Networks, we simply continue where we left off.
representation learningrepresentation learning universal approximatorsuniversal approximators double descentdouble descent
Namely, in §3.1.2 From Localist to Distributional Representations, we cover XYZ and in §3.1.3 From Linear Perceptrons to Non-Linear Multi Layer Perceptrons. we increase the expressivity of model architecture from linear perceptrons otherwise known as single-layer networks to non-linear multi-layer perceptronsmulti-layer perceptron or deep neural networksdeep neural network. Moreover, because deep networks are no longer linear, in §3.1.4 From Direct to Iterative Optimization we transition from from direct optimizatio methods that solve for the exact solution to iterative ones that solve for approximate solutions.
After Part II. Neural Networks, we will be ready for Part III. Scaling Networks where we finally complete our quest in building our own
language model such as ChatGPT by following Nanochat and subsequently, our own deep learning framework capable of running said language model like PyTorch 2,
by implementing 
borscht.compile() and 
borscht.distributed().
Part three follows the 2020-2025 time period of what is colloquially known as the
the age of scalingage of scaling
where researchers focused on scaling up the generality of transformer architecture
by adding additional phases after pre-trainingpre-training stochastic parrots.
That is, the mid-trainingmid-training
and post-trainingpost-training phases
which respectively produce helpful assistantsassistants with
reinforcement learning from human feedback and slow thinking reasonersreasoners with verifiable rewards.
But before we get there we have some work to do.
Let’s go.
II. Table of Contents
- 3. Self-Supervised Pre-training with Deep Networks
- Intermezzo Three: The Language of Optimization and Approximation
- 4. The Engine of Automatic Differentiation
3. Self-Supervised Pre-Training with Deep Networks
In which we transition from manual feature engineering implemented by single layer networks to automatic feature learning by rediscovering various inductive biases that are parameterized by neural network architectures including the FFN, CNN, RNN, LSTM, and GPT. Such parameters are optimized with iterative methods using the approximation from the language of differential calculus afforded by
borscht.funcandborscht.Tensor.backward(). At the end, we will have rediscovered the so-called age of research for ourselves, which spanned form the years 2012 to 2019.
3.1 Learning Static Representations with FFNs
In which we transition from machine learning to deep learning, which focuses on learning distributional representations automatically with deep nets; introduce such deep nets as multi-layer perceptrons, the non-linear extensions of linear models with the ReLU and Embed primitives; transition our optimization techniques to handle non-linear functions with iterative descent methods.
3.1.1 From Feature Engineering to Representation Learning
Table of Contents (§3.1 Learning Representations with FFNs)
Deep learningdeep learning builds off from such foundation, namely, programming of software 2.0 with the three step process of selecting a model architecture, scoring it with a loss, and optimizing such loss. However, deep learning focuses on the class of models known as deep neural networks or multi-layer perceptrons that introduce many layers of non-linearities that make them well-suited for learning representationsrepresentation learningSee (Bengio et al. 2012) and (LeCun et al. 2015).
localist representationlocalist representation distributional representationdistributional representation
Localist representations such as one hot vectors are used to program the software 2.0 of machine learning, whereas distributional representationsdistributional representation such as embeddingsembeddings are used to program the software 2.0 of deep learning. In this chapter, we will learn about the neural network primitive of Embed.
Stanford CS224N: NLP with Deep Learning | Lecture 1 - Intro and Word Vectors
Stanford CS224N: NLP with Deep Learning | Lecture 2 - Word Vectors and Language Models
Namely, in §3.1.2 From Localist to Distributional Representations, we cover XYZ and in §3.1.3 From Linear Perceptrons to Non-Linear Multi Layer Perceptrons. we increase the expressivity of model architecture from linear perceptrons otherwise known as single-layer networks to non-linear multi-layer perceptronsmulti-layer perceptron or deep neural networksdeep neural network. Moreover, because deep networks are no longer linear, in §3.1.4 From Direct to Iterative Optimization we transition from from direct optimizatio methods that solve for the exact solution to iterative ones that solve for approximate solutions.
| 2.0 goal | 2.0 method (Part I) | 2.0 method (Part II) |
|---|---|---|
| evaluation | next-token prediction with stochastics of probability theory and compression of information theory | next-token prediction with stochastics of probability theory and compression of information theory |
| architecture | linear single-layer perceptron with dimensionality of linear algebra | non-linear multi-layer perceptron with dimensionality of linear algebra |
| optimizers | direct solvers with linear systems of linear algebra | iterative solvers with approximation of differential calculus |
| systems | speed of light acceleration with rooflines of cpus | speed of light acceleration with rooflines of gpus |
In §3. Self-Supervised Pre-Training with Deep Networks, we will implement different neural network architectures that all express various inductive biasesinductive biases. Such inductive biases were all explored during the 2012-2019 time period of what is coloquially known as the age of researchage of research. Keeping the goal of next-token prediction constant, we start from the standard feedforward neural networksFFN (FFN), and then transition to more novel architectures such as convolutional neural networksCNN (CNN), recurrent neural networksRNN (RNN), and finally, generative pre-trained transformersGPT (GPT), specifically by reproducing GPT-2Presented in Language Models are Unsupervised Multitask Learners (Radford et al. 2019) following Nanogpt. Because deep neural networks are no longer linear functions of their parameters, we will need to transition from direct methods to iterative ones with descent-based searches. Starting from gradient descentgradient descent, momentummomentum, weight decayweight decay, AdamWAdamW.
Then, in §4. The Engine of Automatic Differentiation,
you will evolve the implementation of 
borscht from a numerical linear algebra library like Numpy
from §2. The Multidimensional Parallelized Tensor into
a full blown batteries-included deep learning framework like TORCH which will be able to run the training and inference of the said nets just trained in §3.
This means implementing the optimizers for neural network primitives whose evaluations are accelerated with massively parallel processors known as graphics processing unitsGPU (GPUs)
and whose gradients are automatically evaluated with an automatic differentationautomatic differentiation engine.
Consult last few chapters of (Jurafsky and Martin 2026)If Jurafsky and Martin is not your cup of tea, take a look at (Eisenstein 2019)., and (Bishop and Bishop 2024)Similarly, you can also take a look at (Goodfellow et al. 2016) for deep learning.
junk
Newton’s classical mechanics must be understood before Einsten’s general relativity.
Similarly, so too of Rosenblatt’s machine learning before that of Bengio, Hinton, and LeCun’s deep learning.
First, we understand that language models such as Nanogpt, ChatGpt, and Claude
are specified as next-token predictors which although decide on a final classifying token for the given context (todo ?), have a step of inference by modeling language stochastically with a conditional random variable endowed with a probability distribution where such distribution corresponds to an array
which can be represented by a 
borscht.Tensor with .shape of (V).
This was the first step in our transition from classical software 1.0 to the jazz of software 2.0,
namely, by transitioning from variables to random variables in §1.1 From Certain to Uncertain Knowledge.
(expand with coterell formalization)
Second, we understanding that such language models probabilistically specified as autoregressive next-token predictors are implemented by estimating the parameterization of their functions with weights and bias so that where is a function that takes in a one-hot vector as input (expand, context?), evaluates the weighted sum with which is the standard inner product of the dot product, and then either uses the dot product directly in the case of linear regression with §1.3 Parameterizing Classification with Linear Regression or further composes the output with a squashing function in §1.4 Parameterizing Classification with Logistic Regression. In either case, both models are specified dimensionally, and, in the case of linear models, can be implemented with the direct optimization of linear algebra’s linear systems as well
3.1.2 From Linear Perceptrons to Non-Linear Multi Layer Perceptrons
Table of Contents (§3.1 Learning Representations with FFNs)
Linear models such as a single layer of perceptrons are used to program the software 2.0 of machine learning, whereas non-linear modelsnon-linear models such as multi-layer perceptronsmulti-layer perceptrons are used to program the software 2.0 of deep learning. We will compose the primitives of Embed, Linear, and RelU together to form a deep neural network.
- wed sep.23 after the 3.1 source of flow
- work on architecture 3.1.3 (merge the various representations) and optimizers 3.1.4
- including synthesis from intermezzo
junk1 With a solid understanding of how statistical learning recovers stochastic maps of data in higher dimensional coordinate spaces, you are now ready to start training your first deep neural networks. The subdiscipline of training deep neural nets follows the same practice of given some dataset , declaring your model’s forward pass , it’s scoring function and resulting backward pass , and iteratively optimizing such score with gradient descent, where . The only difference as we’ll shortly see soon is that the inductive bias used to model the function includes non-linearities with many layers of composition. This second part of the book focuses on the unsupervised/self-supervised task of sequence learning building up to the transformer architecture behind GPT2, but let’s train our first nets using the same tasks from part one to ensure we understand the core foundation of deep neural nets.
junk2 In part 2 we modify and increase the expressivity of the function class by including non-linearities . The feedforward neural network simply put is a series of linear and non-linear layers of the form so
where , is an elementwise nonlinearity, and . Conceptually, the linear layers are performing linear transformations that rotate, reflect, shear, and scale space, whereas the nonlinear transformations perform transformations that squash and twist space.
junk3 Let’s now turn out attention to the function bodies of these ’s with a deep neural netork of hidden layer, carrying out the task of price regression and sentiment classification so that has the form . With the statistical learning foundation from part one, we will simply present the forward pass , the loss function , and the backward pass .
Stanford CS109 | Deep Learning | Lecture 25
Stanford CS229 Machine Learning | Lecture 7: Neural Networks (Architecture)
Stanford CS229 Machine Learning | Lecture 13: LLMs, Next-Word Prediction Loss
Forward Pass
The functions of deep neural networks will take the form of with being a linear classifier on feature extractor
where is the number of compositional layers, and each intermediate function has the form of .
But what is a neural network? | Deep learning chapter 1 (3Blue1Brown 2017)
The next natural question to ask then is whether the logistic regression model is considered a deep neural network? The answer is that technically yes, it can be considered a degenerative deep neural network with hidden layersIn the same way that a list can be considered a degenerative binary tree or graph. These so-called hidden layers automate the construction of the representation through learning, so that the model not only discovers the mapping from representation to output, but also the representation itself. The functions of deep neural networks will take the form of with being a linear classifier on feature extractor , where is the number of compositional layers, and each intermediate function has the form of . Each hidden layer successively and gradually lifts the complexity and abstraction of the data’s representation.
Together, these two aspects of learning non-linear, representations form the essence of deep learning.
"""
model: Neural Language Models (Bengio et al. 2003) URL: https://www.jmlr.org/papers/volume3/bengio03a/bengio03a.pdf
Dimension key:
# windows
B: batch size
T: sequence length
# input/output
V: vocabulary size
E: embedding dimension
D: model dimension
"""
import torch
import matplotlib.pyplot as plt
%matplotlib inline
# from jaxtyping import ...
g = torch.Generator().manual_seed(1337) # for .randn()
B, T = 32, 3
V, E, D = 27, 10, 200
# step: 0/200000, loss 27.63208770751953
# -> expected loss = nll = p(c) = -torch.tensor(1/V=27).log() = 3.2958
# -> self.W = torch.randn() is sampling from N(0, 1)
# -> self.W * [gain/sqrt(D_in)] (torch.init_kaimingnormal())
# residuals + normalization + Adam/RMSprop has made initialization less fragile
# -> b/c initialization is fragile/intractable with *deep* neural networks
class Linear:
def __init__(self, D_in, D_out, bias=True):
self.W_DiDo = torch.randn((D_in, D_out), generator=g) * (5/3)/D_in**0.5 # kaiming init (He et al. 2015)
self.b_Do = torch.zeros(D_out) if bias else None
def __call__(self, X_Di):
self.X_Do = X_Di @ self.W_DiDo
if self.b_Do is not None:
self.X_Do += self.b_Do
self.out = self.X_Do
return self.X_Do
def parameters(self):
return [self.W_DiDo] + ([] if self.b_Do is None else [self.b_Do])
class BatchNorm1D:
def __init__(self, dim, eps=1e-5, momentum=0.1):
self.eps = eps
self.momentum = momentum
self.training = True
# parameters (trained with backprop)
self.gamma = torch.ones(dim)
self.beta = torch.zeros(dim)
# buffers (trained with a running 'momentum update')
self.running_mean = torch.zeros(dim)
self.running_var = torch.ones(dim)
def __call__(self, x):
# calculate the forward pass
if self.training:
xmean = x.mean(0, keepdim=True) # batch mean
xvar = x.var(0, keepdim=True) # batch variance
else:
xmean = self.running_mean
xvar = self.running_var
xhat = (x - xmean) / torch.sqrt(xvar + self.eps) # normalize to unit variance
self.out = self.gamma * xhat + self.beta
# update the buffers
if self.training:
with torch.no_grad():
self.running_mean = (1 - self.momentum) * self.running_mean + self.momentum * xmean
self.running_var = (1 - self.momentum) * self.running_var + self.momentum * xvar
return self.out
def parameters(self):
return [self.gamma, self.beta]
class Tanh:
def __call__(self, X_BD):
self.X_BD = torch.tanh(X_BD)
# plt.hist(self.X_BD.view(-1).tolist(), 50); # distribution of weights
# plt.imshow(self.X_BD.abs() > 0.99, cmap='gray', interpolation='nearest') # vanishing gradients
self.out = self.X_BD
return self.X_BD
def parameters(self):
return []
model = [
Linear(T * E, D, bias=False), BatchNorm1D(D), Tanh(),
Linear(D, D, bias=False), BatchNorm1D(D), Tanh(),
Linear(D, V, bias=False), BatchNorm1D(V)
]
C = torch.randn((V,E), generator=g)
params = [C] + [p for l in model for p in l.parameters()]
for p in params:
p.requires_grad = True
To summarize, in order to transition from manually handcrafting features to automatically learning them, we transition our model parameterizations from single layer to multi-layer perceptrons with non-linearities that learn a progressive sequence of semantically meaningful linear transformations and bases upon optimization (which we’ll learn and implement in 3.1.3 From Direct to Iterative Optimization). In fact, because of such non-linearities, multi-layer perceptrons can in theory approximate any function as stated by the universal approximation theoremThis is the software 2.0 parallel to software 1.0’ turing machines which are capable of universal computation. See https://en.wikipedia.org/wiki/Universal_Turing_machine. Put shortly, a multi-layer perceptron is
where and are supported by and respectively, , and of course . To score such parameters, we continue to maintain the cross entropy loss because the overall classification from input to output space remains unchanged. That is, the function we’d like to recover is a next-token predictor that categorizes a possible next output token given an input sentence, and so the cross entropy loss of such a model is,
where and so . Now that we’ve parameterized next-token prediction with a multi-layer perceptron and have scored such parameters with the cross-entropy loss, let us move on to it’s optimization.
3.1.3 From Direct to Iterative Optimization
Table of Contents (§3.1 Learning Representations with FFNs)
Direct optimization methods used to optimize the parameters of a linear perceptron such as iterative reweighted least squares will not work for the parameterized family of non-linear multi-layer perceptrons. Consider a parameterization of the multilayer perceptron and scoring such parameters with the cross entropy loss. That is,
import borscht
import borscht.nn as nn
import borscht.nn.functional as F
class Linear(nn.Module):
def __init__(self, Di: int, Do: int):
self.W_DiDo = borscht.randn((Di,Do))
self.b_Do = borscht.randn((Do))
def __call__(self, x_Di: borscht.Tensor):
out_Do = x_Di @ self.W_DiDo
if self.b_Do is not None:
out_Do += self.b_Do
return out
class Tanh():
def __call__(self, X_BD: borscht.Tensor):
return borscht.tanh(X_BD)
V = 2
T = 1
E = 10
model = [Linear(T*E, D), Tanh()]
yhat = model(x)
loss_scalar = F.cross_entropy(yhat,y)
Similar to the linear perceptron used as a probabilistic model, the direct method of gaussian elimination does not suffice. Why so? (insert explanation)
Stanford CS229 Machine Learning | Lecture 8: Neural Networks 2 (Backprop) Stanford CS224N: NLP with Deep Learning | Lecture 3 - Backpropagation, Neural Network
Rather than find the optimal set of parameters in closed form solution directly, we need to transition our optimization method from an iterativeiterative optimization one that incrementally finds increasingly better approximations of the optimum with a sequence that takes steps. Take a look at the 3Blue1Brown visual lecture below for an intuition pump on where we’re going this chapter.
Similarly, before we iteratively optimize the original cross entropy loss function of our multi-layer perceptron which produces a real-valued score for many parameters that have support in d-dimensional Euclidean space, let us consider loss functions of the one and two dimensional case first. That is, consider some arbitrary non-linear loss function that scores a single parameter
import borscht
def my_1dloss(theta: borscht.Tensor) -> borscht.Tensor:
loss_scalar = borscht.Tensor(
((theta+2)**2 *
(theta-1)**2 *
(theta-3)**2) + 0.3
)
return loss_scalar
theta_scalar = borscht.randn()
my_1dloss_scalar = my_1dloss(theta_scalar())
as well as it’s corresponding plot of a curvecurve
import matplot
plt.plot(my_1dloss)
(insert plot)
Instead of solving for the that minimizes in closed-form directly, we can solve so iteratively. Recalling the derivative’sderivative geometric perspective from high school calculus, the derivative of a function at a certain point is the line tangenttangent to the output, which is the instantaneous rate of change between two points of a secantsecant line. This secant line corresponds to the analytical perspective of the derivativederivative defined by the limitlimit.
Geometrically speaking, starting from some randomly initialized parameter with borscht.randn(todo),
we can take iterative steps towards the minimum by __.
Now, consider another arbitrary non-linear loss function that scores not a single parameter, but rather two parameters
import borscht
def my_1dloss(theta: borscht.Tensor) -> borscht.Tensor:
loss_scalar = borscht.Tensor(
((theta+2)**2 *
(theta-1)**2 *
(theta-3)**2) + 0.3
)
return loss_scalar
theta_scalar = borscht.randn()
my_1dloss_scalar = my_1dloss(theta_scalar())
as well as it’s corresponding plot of a surfacesurface
gradientgradient with 
borscht.grad()
derivativederivative defined by the limitlimit as a linear operator
import matplot
plt.plot(my_1dloss)
(insert plot)
# 1. dataloader
import torch.nn.functional as F
import random
words = open('./data/names.txt', 'r').read().splitlines()
v = sorted(list(set(''.join(words))))
encode = { c:i+1 for i,c in enumerate(v) }
encode['.'] = 0
decode = { i:c for c,i in encode.items() }
def gen_dataset(words):
X, Y = [], []
for w in words:
context = [0] * T;
for c in w + '.':
X.append(context)
Y.append(encode[c])
# print(''.join(decode[i] for i in context), '-->', decode[encode[c]])
context = context[1:] + [encode[c]]
X, Y = torch.tensor(X), torch.tensor(Y) # X:(N,C) Y:(N)
return X, Y
random.seed(42)
random.shuffle(words)
n1, n2 = int(0.8*len(words)), int(0.9*len(words))
Xtr, Ytr = gen_dataset(words[:n1])
Xdev, Ydev = gen_dataset(words[n1:n2])
Xte, Yte = gen_dataset(words[n2:])
# 2. training loop
N = Xtr.shape[0]
losses, steps = [], []
for step in range(200000):
# 1. forward
indices_B = torch.randint(0, N, (B,))
X_B, Y_B = Xtr[indices_B], Ytr[indices_B]
X_BD = C[X_B].view(-1, T * E)
for layer in model:
X_BD = layer(X_BD)
loss = F.cross_entropy(X_BD, Y_B)
# 2. backward
for layer in model:
layer.out.retain_grad()
for p in params:
p.grad = None
loss.backward()
# 3. update
for p in params:
p.data += -0.01 * p.grad
steps.append(step)
losses.append(loss.log10().item())
if step % 10000 == 0:
print(f"step: {step}/{200000}, loss {loss.item()}")
plt.plot(steps, losses)
step: 0/200000, loss 3.811568260192871 step: 10000/200000, loss 2.625439405441284 step: 20000/200000, loss 2.426504135131836 step: 30000/200000, loss 1.8564293384552002 step: 40000/200000, loss 2.563091993331909 step: 50000/200000, loss 1.8718260526657104 step: 60000/200000, loss 2.189483642578125 step: 70000/200000, loss 1.9642211198806763 step: 80000/200000, loss 2.261333465576172 step: 90000/200000, loss 2.2367687225341797 step: 100000/200000, loss 1.7542214393615723 step: 110000/200000, loss 1.9069358110427856 step: 120000/200000, loss 2.2940409183502197 step: 130000/200000, loss 2.030090808868408 step: 140000/200000, loss 2.556252956390381 step: 150000/200000, loss 2.0693819522857666 step: 160000/200000, loss 2.2053565979003906 step: 170000/200000, loss 2.3926899433135986 step: 180000/200000, loss 2.4394421577453613 step: 190000/200000, loss 2.0519824028015137
[<matplotlib.lines.Line2D at 0x11099cce0>]
3.2 Iterative Optimization
In which we delve deeper into foundations of gradient-based iterative optimization, building ourselves up to the AdamW optimizer.
3.2.1 Taylor Series and their Derivatives, Gradients, and Hessians
Lorem ipsum MIT 6.7960 Deep Learning: Lec 07. Scaling Rules for Optimization dolor sit amet, consectetur adipiscing elit. Proin finibus interdum arcu, ut venenatis dolor rutrum eget. Nulla nec urna tellus. Donec sollicitudin hendrerit sollicitudin. Proin sagittis, velit commodo congue rhoncus, metus turpis hendrerit nisi, a accumsan quam ante ultricies sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Ut aliquet ut justo non posuere. Quisque in rutrum nunc. Phasellus vehicula tortor mi, vehicula placerat diam ultrices eget. Aliquam et consequat eros, quis fermentum tortor. Sed eu nisl viverra, auctor velit vel, elementum ante. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Proin aliquet metus sit amet nulla cursus maximus in feugiat mi. Praesent vehicula magna sed scelerisque elementum. Nulla euismod rutrum turpis sed hendrerit.
3.2.2 First Order Optimization
3.2.3 Second Order Optimization
3.2.4 Momentum
3.2.5 Weight Decay
3.3 Learning Representations with CNNs
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin finibus interdum arcu, ut venenatis dolor rutrum eget. Nulla nec urna tellus. Donec sollicitudin hendrerit sollicitudin. Proin sagittis, velit commodo congue rhoncus, metus turpis hendrerit nisi, a accumsan quam ante ultricies sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Ut aliquet ut justo non posuere. Quisque in rutrum nunc. Phasellus vehicula tortor mi, vehicula placerat diam ultrices eget. Aliquam et consequat eros, quis fermentum tortor. Sed eu nisl viverra, auctor velit vel, elementum ante. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Proin aliquet metus sit amet nulla cursus maximus in feugiat mi. Praesent vehicula magna sed scelerisque elementum. Nulla euismod rutrum turpis sed hendrerit.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin finibus interdum arcu, ut venenatis dolor rutrum eget. Nulla nec urna tellus. Donec sollicitudin hendrerit sollicitudin. Proin sagittis, velit commodo congue rhoncus, metus turpis hendrerit nisi, a accumsan quam ante ultricies sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Ut aliquet ut justo non posuere. Quisque in rutrum nunc. Phasellus vehicula tortor mi, vehicula placerat diam ultrices eget. Aliquam et consequat eros, quis fermentum tortor. Sed eu nisl viverra, auctor velit vel, elementum ante. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Proin aliquet metus sit amet nulla cursus maximus in feugiat mi. Praesent vehicula magna sed scelerisque elementum. Nulla euismod rutrum turpis sed hendrerit.
3.4 Learning Representations with RNNs
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin finibus interdum arcu, ut venenatis dolor rutrum eget. Nulla nec urna tellus. Donec sollicitudin hendrerit sollicitudin. Proin sagittis, velit commodo congue rhoncus, metus turpis hendrerit nisi, a accumsan quam ante ultricies sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Ut aliquet ut justo non posuere. Quisque in rutrum nunc. Phasellus vehicula tortor mi, vehicula placerat diam ultrices eget. Aliquam et consequat eros, quis fermentum tortor. Sed eu nisl viverra, auctor velit vel, elementum ante. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Proin aliquet metus sit amet nulla cursus maximus in feugiat mi. Praesent vehicula magna sed scelerisque elementum. Nulla euismod rutrum turpis sed hendrerit.
class RNNCell(nn.Module):
"""
the job of a 'Cell' is to:
take input at current time step x_{t} and the hidden state at the
previous time step h_{t-1} and return the resulting hidden state
h_{t} at the current timestep
"""
def __init__(self, config):
super().__init__()
self.xh_to_h = nn.Linear(config.n_embd + config.n_embd2, config.n_embd2)
def forward(self, xt, hprev):
xh = torch.cat([xt, hprev], dim=1)
ht = F.tanh(self.xh_to_h(xh))
return ht
class RNN(nn.Module):
def __init__(self, config, cell_type):
super().__init__()
self.block_size = config.block_size
self.vocab_size = config.vocab_size
self.start = nn.Parameter(torch.zeros(1, config.n_embd2)) # the starting hidden state
self.wte = nn.Embedding(config.vocab_size, config.n_embd) # token embeddings table
if cell_type == 'rnn':
self.cell = RNNCell(config)
elif cell_type == 'gru':
self.cell = GRUCell(config)
self.lm_head = nn.Linear(config.n_embd2, self.vocab_size)
def get_block_size(self):
return self.block_size
def forward(self, idx, targets=None):
device = idx.device
b, t = idx.size()
# embed all the integers up front and all at once for efficiency
emb = self.wte(idx) # (b, t, n_embd)
# sequentially iterate over the inputs and update the RNN state each tick
hprev = self.start.expand((b, -1)) # expand out the batch dimension
hiddens = []
for i in range(t):
xt = emb[:, i, :] # (b, n_embd)
ht = self.cell(xt, hprev) # (b, n_embd2)
hprev = ht
hiddens.append(ht)
# decode the outputs
hidden = torch.stack(hiddens, 1) # (b, t, n_embd2)
logits = self.lm_head(hidden)
# if we are given some desired targets also calculate the loss
loss = None
if targets is not None:
loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1), ignore_index=-1)
return logits, loss
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin finibus interdum arcu, ut venenatis dolor rutrum eget. Nulla nec urna tellus. Donec sollicitudin hendrerit sollicitudin. Proin sagittis, velit commodo congue rhoncus, metus turpis hendrerit nisi, a accumsan quam ante ultricies sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Ut aliquet ut justo non posuere. Quisque in rutrum nunc. Phasellus vehicula tortor mi, vehicula placerat diam ultrices eget. Aliquam et consequat eros, quis fermentum tortor. Sed eu nisl viverra, auctor velit vel, elementum ante. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Proin aliquet metus sit amet nulla cursus maximus in feugiat mi. Praesent vehicula magna sed scelerisque elementum. Nulla euismod rutrum turpis sed hendrerit.
3.5 Learning Dynamic Representations with GPTs
In which we transition from long-range depencies of recurrent neural nets to the ___ of transformer-based neural nets.
3.5.1 Dynamic Embeddings with Attention
Table of Contents (§3.5 Learning Representations with GPTs)
Attention is all you need. static embeddingsstatic embeddings dynamic embeddingsdynamic embeddings Stanford CS229 Machine Learning Lecture 14: Transformers, In-Context Learning
Stanford CME295 Transformers & LLMs | Lecture 1 - Transformer
Stanford CS25: V2 I Introduction to Transformers w/ Andrej Karpathy
fluffy blue creature roamed the verdant forest. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
microgpt: https://gist.github.com/karpathy/8627fe009c40f57531cb18360106ce95
class Block(nn.Module):
def __init__(self, config):
super().__init__()
D, H = config.n_embd, config.n_head
self.ln_1 = nn.LayerNorm(D)
self.attn = MHA(config)
self.mlp = FFN(config) # .mlp for HF
self.ln_2 = nn.LayerNorm(D)
def forward(self, X_BTD):
# residuals:
# - (He et al. 2015 https://arxiv.org/abs/1512.03385)
# - (Elhage et al. 2021 https://transformer-circuits.pub/2021/framework/index.html)
X_BTD = X_BTD + self.attn(self.ln_1(X_BTD))
X_BTD = X_BTD + self.mlp(self.ln_2(X_BTD))
return X_BTD
class MHA(nn.Module):
def __init__(self, config):
super().__init__()
T, D, H = config.block_size, config.n_embd, config.n_head
assert D % H == 0
self.H = H
self.c_attn = nn.Linear(D, 3 * D)
self.c_proj = nn.Linear(D, D)
self.c_proj.GPT2_SCALE_INIT = 1
self.register_buffer('bias', torch.tril(torch.ones(T, T)).view(1, 1, T, T)) # tril -> bias for HF
def forward(self, X_BTD):
B,T,D = X_BTD.shape
H = self.H
# 1. project to learned QKV subspaces Q=WqX, K=WkX, V=WvX
Wq_DK, Wk_DK, Wv_DK = self.c_attn(X_BTD).split(D, dim=2)
Q_BHTK, K_BHTK, V_BHTK = Wq_DK.view(B, T, H, D // H).transpose(1, 2), Wk_DK.view(B, T, H, D // H).transpose(1, 2), Wv_DK.view(B, T, H, D // H).transpose(1, 2)
# 2. evaluate scores A(QKV) = softmax(QK^T/sqrt(d_k))V
# A_BHTT = Q_BHTK @ K_BHTK.transpose(-2, -1) * (1.0 / math.sqrt(K_BHTK.size(-1)))
# A_BHTT = A_BHTT.masked_fill(self.bias[:, :, :T, :T]==0, float('-inf'))
# A_BHTT = F.softmax(A_BHTT, dim=-1) # todo, when dim=-1?
# S_BHTD = A_BHTT @ V_BHTK
S_BHTD = F.scaled_dot_product_attention(Q_BHTK, K_BHTK, V_BHTK, is_causal=True)
# 3. contextualize the embeddings
S_BTD = S_BHTD.transpose(1, 2).contiguous().view(B, T, D) # performs cat
S_BTD = self.c_proj(S_BTD)
return S_BTD
3.5.2 Storing Facts with FFN
Table of Contents (§3.5 Learning Representations with GPTs)
Storing facts consectetur adipiscing elit. Donec aliquam varius mi, pharetra scelerisque nulla vehicula ut. Cras ac faucibus dolor. Nunc tempus efficitur ultricies. Nullam non fermentum elit, et tincidunt ante. Donec hendrerit metus ac urna semper, in facilisis leo egestas. Integer sit amet quam ultrices, dapibus sem a, volutpat velit. Pellentesque eget ipsum consequat sem luctus blandit a et nibh. Mauris gravida felis ut dignissim volutpat. Quisque volutpat pulvinar enim in venenatis. Integer posuere tincidunt leo vel congue. Sed ex velit, posuere ut nibh nec, ultrices malesuada nibh. Fusce scelerisque mollis neque id fringilla. Etiam rhoncus quam a leo ultrices rutrum non vitae est. Donec bibendum, arcu sagittis consequat vestibulum, lectus elit bibendum libero, non rhoncus lorem lorem a neque. Sed ex felis, rutrum non consequat eget, tincidunt ac lacus.
class FFN(nn.Module):
def __init__(self, config):
super().__init__()
D = config.n_embd
self.c_fc = nn.Linear(D, 4*D) # projecting up to extract features from context embeddings
self.gelu = nn.GELU(approximate='tanh') # (Hendrycks and Gimpel 2016 https://arxiv.org/abs/1606.08415)
self.c_proj = nn.Linear(4*D, D) # projecting back down to residual pathway
self.c_proj.GPT2_SCALE_INIT = 1
def forward(self, X_BTD):
X_BT4D = self.c_fc(X_BTD)
X_BT4D = self.gelu(X_BT4D)
X_BTD = self.c_proj(X_BT4D)
return X_BTD
3.5.2 Embedding and Unembedding
Table of Contents (§3.5 Learning Representations with GPTs)
3.5.3 Optimizing Loss with AdamW on Cross Entropy
Table of Contents (§3.5 Learning Representations with GPTs)
Intermezzo Three: The Language of Optimization and Approximation
The learning process continues to be a three-step one where we specify a parameterization, a scoring of such parameters, and the optimization of such scoring function
While automatically learning features for next-token prediction maintains the cross entropy loss for the scoring of parameters, the parameterization changes so that optimizing such loss of it’s parameters if a process such that a progressive sequence of semantically meaningful linear transformations that squash, shear, and rotate n-dimensional Euclidean space is learned
import borscht
import borscht.nn as nn
import borscht.nn.functional as F
class MLP(nn.Module):
def __init__(self):
raise NotImplementedError("")
def __call__(self, x_E: borscht.Tensor):
raise NotImplementedError("")
model = MLP(Din, Dout)
# random forward pass
x = borscht.Tensor()
y_logits = model(x)
y_distribution = F.cross_entropy_loss(y_logits)
and now that the model class is non-linear, said optimization changes to an iterative process of gradient descent.
model = MLP(Din, Dout)
for t in range(100):
# 1. forward
x = borscht.Tensor()
y_logits = model(x)
y_distribution = F.cross_entropy_loss(y_logits, ___)
# 2. backward
y_distribution.backward()
optim.step()
For this iterative optimization process, we earlier appealed to the intuitive geometric perspective of the derivative, where in the one and two-dimensional cases, the derivative of the loss function is viewed as the line and plane that is tangent to the function’s output and points in the direction up the curve and surface’s hill. Although we were unable to visualize the gradient of the n-dimensional case of the actual loss function with n being the size of the parameter space, we could generalize the gradient by appending a partial derivative for each additional parameter that the loss function takes in as an argument. That is,
print(loss.backward())
Now that we intuitively understand how the notions of descent and sensitivity from the language of optimization and approximation are used in the training deep neural networks from the geometric and computational perspective, let us analytically formalize such notions into precise concepts.
I3.1 Sequences
I3.2 Limits of Sequences
I3.3 Limits of Functions
I3.3 Derivatives as Linear Operators
4. The Engine of Automatic Differentiation
automatic differentiationautomatic differentiation optimizersoptimizers network primitivesnetwork primitives parallel programmingparallel programming massively parallel processorsmassively parallel processors graphics processing unit (GPU)GPU CUDA RustCUDA Rust PTXPTX SASSSASS
Finish strong young hacker.
4.1 Automatic Differentation
In which we …
4.1.1 Algorithmic Differentiation
Table of Contents (§4.1 Automatic Differentiation)
Automatic Differentiation as opposed to symbolic or numeric differentiation can be considered an algorithmic implementation of differentiation on a computational graph.
4.1.2 Implementing Backward for FFN
Table of Contents (§4.1 Automatic Differentiation)
4.1.5 Implementing Backward for GPT
Table of Contents (§4.1 Automatic Differentiation)
4.2 Gradient-Based Optimization
Tensor.backward() function in order to materialize the gradient of the loss on our
parameter weights which gradient descent uses in it’s update rule
.
We will now dive deeper into how .backward() is implemented. –>
4.1.4 Stochastic Gradient Descent
4.1.5 Adam
Table of Contents (§4.1 Automatic Differentiation)
4.2.3 Implementing Forward Pass for CNN
4.2.4 Implementing Forward Pass for RNN
4.2.5 Implementing Forward Pass for GPT
missing notebook: /opt/build/repo/sitp/notebooks/4.1_makemore.ipynb
missing notebook: /opt/build/repo/sitp/notebooks/4.1_makemore.ipynb
4.3 From Strides to Layouts
4.4 Accelerating Attention

Presenting an early outline of SITP at Toronto School of Foundation Modeling Season 1 (2025)
Afterword
Congratulations on completing your adventure through the whirlwind tour of The Structure and Interpretation of Tensor Programs.
To continue deepening your knowledge of deep learning and deep learning systems,
we provide explicit offramps that bridge you from nanochat to marin as well as from borscht to torch and vllm.
Good luck on your journey.
I’ll see you at work.
Afterward Table of Contents
A. From nanochat to marin
B. From borscht to torch
Borscht is an array programming language with support for automatic differentiation and gpu acceleration following PyTorch1’s eager mode outlined in (Paszke et al. 2019) as well as compilation and distributed training following PyTorch2’s graph mode outlined in (Ansel et al. 2024).
import torch
import torch.nn as nn
import torch.nn.functional as F
class MLP(nn.Module):
"""
takes the previous block_size tokens, encodes them with a lookup table,
concatenates the vectors and predicts the next token with an MLP.
Reference:
Bengio et al. 2003 https://www.jmlr.org/papers/volume3/bengio03a/bengio03a.pdf
"""
def __init__(self, config):
super().__init__()
self.block_size = config.block_size
self.vocab_size = config.vocab_size
self.wte = nn.Embedding(config.vocab_size + 1, config.n_embd) # token embeddings table
# +1 in the line above for a special <BLANK> token that gets inserted if encoding a token
# before the beginning of the input sequence
self.mlp = nn.Sequential(
nn.Linear(self.block_size * config.n_embd, config.n_embd2),
nn.Tanh(),
nn.Linear(config.n_embd2, self.vocab_size)
)
def get_block_size(self):
return self.block_size
def forward(self, idx, targets=None):
# gather the word embeddings of the previous 3 words
embs = []
for k in range(self.block_size):
tok_emb = self.wte(idx) # token embeddings of shape (b, t, n_embd)
idx = torch.roll(idx, 1, 1)
idx[:, 0] = self.vocab_size # special <BLANK> token
embs.append(tok_emb)
# concat all of the embeddings together and pass through an MLP
x = torch.cat(embs, -1) # (b, t, n_embd * block_size)
logits = self.mlp(x)
# if we are given some desired targets also calculate the loss
loss = None
if targets is not None:
loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1), ignore_index=-1)
return logits, loss
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
if __name__ == '__main__':
# todo: parser
args = parser.parse_args()
print(vars(args))
# system inits
torch.manual_seed(args.seed)
torch.cuda.manual_seed_all(args.seed)
os.makedirs(args.work_dir, exist_ok=True)
writer = SummaryWriter(log_dir=args.work_dir)
# init datasets
train_dataset, test_dataset = create_datasets(args.input_file)
vocab_size = train_dataset.get_vocab_size()
block_size = train_dataset.get_output_length()
print(f"dataset determined that: {vocab_size=}, {block_size=}")
# init model
config = ModelConfig(vocab_size=vocab_size, block_size=block_size,
n_layer=args.n_layer, n_head=args.n_head,
n_embd=args.n_embd, n_embd2=args.n_embd2)
model = MLP(config)
model.to(args.device)
print(f"model #params: {sum(p.numel() for p in model.parameters())}")
if args.resume or args.sample_only: # note: if we sample-only then we also assume we are resuming
print("resuming from existing model in the workdir")
model.load_state_dict(torch.load(os.path.join(args.work_dir, 'model.pt')))
if args.sample_only:
print_samples(num=50)
sys.exit()
# init optimizer
optimizer = torch.optim.AdamW(model.parameters(), lr=args.learning_rate, weight_decay=args.weight_decay, betas=(0.9, 0.99), eps=1e-8)
# init dataloader
batch_loader = InfiniteDataLoader(train_dataset, batch_size=args.batch_size, pin_memory=True, num_workers=args.num_workers)
# training loop
best_loss = None
step = 0
while True:
t0 = time.time()
# get the next batch, ship to device, and unpack it to input and target
batch = batch_loader.next()
batch = [t.to(args.device) for t in batch]
X, Y = batch
# B.1.1: feed into the model
logits, loss = model(X, Y)
# B.1.2: calculate the gradient, update the weights
model.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
break
B.1 Eager Mode with PyTorch1
B.1.1 Forward
Forward
B.1.2 Backward
Backward
B.1.3 Step
Step
B.2 Graph Mode with PyTorch2
B.3 Inference with vLLM
Appendix
A. Snakes and Crabs
2.1.1 Are You Rustic?
B. Fields
C. Sequences, Series, and Limits
D. Junk
4.3 From Sequential Machines to Parallel Machines
The opening call to adventure in Part I. Elements of Networks invited the programmers of Silicon Valley to take a similar journey between the tension of the finite and infinite that the Ancient Greeks took. That is, to contribute towards this new approach of augmenting and amplifying human intelligence, they must climb back down from their current pitch in programming algorithms of sets, maps, lists, trees, and graphs on serial machines to optimizing the distributions of scalars, vectors, matrices, tensors, and neural networks on massively parallel machines. Now is the time to add that final element. That of parallel programming on NVIDIA GPUs with CUDA Rust.
From Modal GPU Glossary (Streaming Multiprocessor), modified from a diagram in Fabien Sanglard’s blog, itself likely modified from a diagram in the CUDA C Programming Guide.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec aliquam varius mi, pharetra scelerisque nulla vehicula ut. Cras ac faucibus dolor. Nunc tempus efficitur ultricies. Nullam non fermentum elit, et tincidunt ante. Donec hendrerit metus ac urna semper, in facilisis leo egestas. Integer sit amet quam ultrices, dapibus sem a, volutpat velit. Pellentesque eget ipsum consequat sem luctus blandit a et nibh. Mauris gravida felis ut dignissim volutpat. Quisque volutpat pulvinar enim in venenatis. Integer posuere tincidunt leo vel congue. Sed ex velit, posuere ut nibh nec, ultrices malesuada nibh. Fusce scelerisque mollis neque id fringilla. Etiam rhoncus quam a leo ultrices rutrum non vitae est. Donec bibendum, arcu sagittis consequat vestibulum, lectus elit bibendum libero, non rhoncus lorem lorem a neque. Sed ex felis, rutrum non consequat eget, tincidunt ac lacus.
Nulla quis dictum diam, a cursus nibh. Pellentesque magna ante, lobortis a aliquam at, imperdiet eget magna. Nam pharetra dolor lectus, eu hendrerit ipsum tincidunt molestie. Cras eget viverra ipsum. Cras consequat metus quis nibh fermentum, egestas vestibulum odio lacinia. Donec ut ante hendrerit, convallis sem pulvinar, pulvinar magna. Etiam consequat mattis turpis non mollis. Sed consequat et sem quis interdum. Vivamus blandit elementum tortor non convallis. Duis quis nunc nisi. Fusce scelerisque felis pellentesque ex elementum imperdiet. Nulla facilisi. Nunc molestie urna sit amet nisl ullamcorper, at convallis augue tempor.
https://nvlabs.github.io/cuda-oxide/getting-started/hello-gpu.html#install-cargo-oxide
So with that all said and done, let us extend the mixed source of Python and Rust for 
borscht with a third language for the devicedevice.
Recall that in §2.1.1 From Virtual to Physical Machines we installed the Rust toolchain so that rustc could translate our program into the machine language of a physical processor. That toolchain is still necessary, but it is no longer sufficient, because the physical machine we are now targeting is not the one running our program. A GPU program is really two programs — the hosthost program which runs on the CPU and orchestrates memory and launches, and the devicedevice program of kernelskernels which runs on the GPU. The two are compiled by two different compilers, for two different instruction sets, and then stitched back together.
Historically the device half of that pair was written in CUDA C++ and compiled by NVIDIA’s nvcc, which meant that a Rust deep learning framework had to reach across a foreign function interface to say anything to the GPU at all. We will not do that. Instead we will keep host and device in the same file and the same language by way of cuda-oxidecuda-oxideSee NVlabs/cuda-oxide. It is explicitly alpha software: expect bugs and API breakage., a codegen backendcodegen backend for rustc which compiles functions annotated #[kernel] down to PTXPTX, the virtual instruction set of NVIDIA GPUsPTX is virtual in exactly the sense of §2.1.1 — it is not what the silicon executes. The driver finishes the job, lowering PTX to the SASSSASS of your particular chip at load time.. Note that this is the same turtle we met in Part I: rustc never emitted x86 directly either, it emitted LLVM. Here it emits LLVM too, only the backend of that backend is NVPTX instead of X86.
So the toolchain grows a device half:
| host | device | |
|---|---|---|
| compiler | rustc | rustc + rustc-codegen-cuda |
| target | x86-64, ARM, RISC-V | PTX → SASS |
| driver | cargo | cargo oxide |
| runtime | — | CUDA Toolkit + NVIDIA driver |
Concretely, you need five things. A Linux machine with an NVIDIA GPU and a driverdriver new enough for your toolkit. The CUDA ToolkitCUDA Toolkit (12.x or newer), which supplies nvcc, libNVVM, nvJitLink and libdevice. LLVM 21 or newer, whose llc performs the final lowering from LLVM IR to PTXThis floor is not negotiable for modern chips. The backend emits TMA, tcgen05 and WGMMA intrinsics that llc from LLVM 20 and earlier cannot lower — simple kernels may survive on an older llc, but anything Hopper or Blackwell will not.. Clang 21 with its development headers, because the host bindings are generated by bindgen, which loads libclang and wants clang’s own stddef.h. And a Rust nightly, pinned by rust-toolchain.toml, carrying the rust-src, rustc-dev and llvm-tools components — nightly because a codegen backend plugs into rustc’s own internals, which are not a stable interface.
That is a long enough list that the honest advice is to not install it by hand. The repository ships a development containerdevelopment container which pins every one of the five, so the only things your host must provide are the NVIDIA driver and the NVIDIA Container ToolkitInstalling the container toolkit is not quite the whole story: it must also be registered with the container runtime, via sudo nvidia-ctk runtime configure --runtime=docker followed by a daemon restart. Without that step --gpus=all has no runtime to bind to and the container starts with no GPU.. The CUDA Toolkit itself never touches your machine.
> root@machine ~/ $ git clone https://github.com/NVlabs/cuda-oxide.git
> root@machine ~/ $ devcontainer up --workspace-folder cuda-oxide
> root@machine ~/ $ devcontainer exec --workspace-folder cuda-oxide bash
Those with Nix may prefer nix develop, which gives the same guarantee by a different road. Those who insist on installing to the metal should consult the project’s installation chapter; the one step people trip on is LLVM, since most distributions do not package a version recent enough, and you will want llvm.sh:
> root@machine ~/ $ wget https://apt.llvm.org/llvm.sh && chmod +x llvm.sh && sudo ./llvm.sh 21
> root@machine ~/ $ llc-21 --version | grep nvptx
nvptx - NVIDIA PTX 32-bit
nvptx64 - NVIDIA PTX 64-bit
However you arrive, the toolchain is driven by cargo oxide, a cargo subcommandcargo subcommand standing in the same relation to cargo that cargo stands to rustc. Inside the cuda-oxide repository it resolves through a workspace alias and works immediately; for a project of our own, such as 
borscht, it must be installed against the pinned nightly:
> root@machine ~/ $ cargo +nightly-2026-04-03 install --git https://github.com/NVlabs/cuda-oxide.git cargo-oxide
Installing /usr/local/cargo/bin/cargo-oxide
Installed package `cargo-oxide v0.2.1` (executable `cargo-oxide`)
Before writing a line of our own, ask it whether the machine is in order. cargo oxide doctor is the whole of the preceding discussion turned into a checklist, and it is worth reading line by line, because every entry is a piece of the pipeline we just described:
> root@machine ~/ $ cargo oxide doctor
cargo-oxide environment check
==============================
Rust nightly toolchain... ✓ rustc 1.96.0-nightly (55e86c996 2026-04-02)
rust-toolchain.toml... ✓ channel nightly-2026-04-03
Pinned toolchain active... ✓ nightly-2026-04-03-x86_64-unknown-linux-gnu
Required rustup components... ✓ rust-src, rustc-dev, rust-analyzer, clippy, rustfmt, llvm-tools
Codegen backend... - not built yet (run `cargo oxide setup`)
CUDA headers (cuda.h)... ✓ /usr/local/cuda/include/cuda.h
CUDA toolkit (nvcc)... ✓ Cuda compilation tools, release 13.0, V13.0.48
libNVVM (libnvvm.so)... ✓ libNVVM 2.0
nvJitLink (libnvJitLink.so)... ✓ nvJitLink 13.0
libdevice (libdevice.10.bc)... ✓ /usr/local/cuda/nvvm/libdevice/libdevice.10.bc
llc (LLVM)... ✓ Ubuntu LLVM version 21.1.8 (/usr/bin/llc-21)
clang / libclang resource dir... ✓ /usr/lib/llvm-21/lib/clang/21
NVIDIA driver / GPU... ✓ NVIDIA A100-SXM4-40GB (compute capability 8.0, driver 580.105.08)
cuda-gdb (optional)... ✓ /usr/local/cuda/bin/cuda-gdb
compute-sanitizer (optional)... ✓ Version 2025.3.0.0
✅ Environment looks good!
Two of those lines deserve a second look. The compute capabilitycompute capability 8.0 is the generation of the chip — sm_80 is Ampere, sm_90 Hopper, sm_100 Blackwell — and it is what decides which instructions are available to us in §4.4; you will meet it again as the thing that makes a kernel fast on one machine and illegal on another. And the codegen backend reported as not built yet is not an error: it is a compiler, rustc-codegen-cuda, and cargo oxide will build and cache it for you on first use.
Now we can scaffold, exactly as maturin new scaffolded the Python and Rust halves of 
borscht back in §2.1.2:
> root@machine ~/ $ cargo oxide new borscht-cuda
✓ Created cuda-oxide project 'borscht-cuda'
cd borscht-cuda
cargo oxide doctor
cargo oxide run
> root@machine ~/ $ cd borscht-cuda && ls
Cargo.toml
README.md
rust-toolchain.toml
src
[package]
name = "borscht-cuda"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
cuda-device = { git = "https://github.com/NVlabs/cuda-oxide.git" }
cuda-host = { git = "https://github.com/NVlabs/cuda-oxide.git" }
cuda-core = { git = "https://github.com/NVlabs/cuda-oxide.git" }
[toolchain]
channel = "nightly-2026-04-03"
components = [
"rust-src",
"rustc-dev",
"rust-analyzer",
"clippy",
"rustfmt",
"llvm-tools",
]
The three dependencies partition the problem the same way the machine does. cuda-device is the vocabulary of the device — #[kernel], thread::index_1d(), the indexing types — and it is the crate whose code rustc-codegen-cuda will consume. cuda-core is the host-side runtime of contexts, streams and buffers. cuda-host supplies #[cuda_module], the macro that welds the two halves together by embedding the compiled device artifact into the host binary and generating a typed launch method for each kernel. And the pinned rust-toolchain.toml is what makes the earlier +nightly-2026-04-03 unnecessary from here on: rustup reads it and selects the toolchain on every cargo invocation, fetching llvm-tools on first use.
The generated src/main.rs is the smallest complete example of the shape all of our kernels will take, and it repays a careful reading before we write our own:
use cuda_device::{kernel, launch_bounds, launch_contract, thread, DisjointSlice};
use cuda_host::cuda_module;
use cuda_core::{CudaContext, DeviceBuffer, LaunchConfig1D};
#[cuda_module]
mod kernels {
use super::*;
#[kernel]
#[launch_bounds(256)]
#[launch_contract(domain = 1, block = (256, 1, 1))]
pub fn vecadd(a: &[f32], b: &[f32], mut c: DisjointSlice<f32>) {
let idx = thread::index_1d();
let idx_raw = idx.get();
if let Some(c_elem) = c.get_mut(idx) {
*c_elem = a[idx_raw] + b[idx_raw];
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let ctx = CudaContext::new(0)?;
let stream = ctx.default_stream();
const N: usize = 1024;
let a_host: Vec<f32> = (0..N).map(|i| i as f32).collect();
let b_host: Vec<f32> = (0..N).map(|i| (i * 2) as f32).collect();
let a_dev = DeviceBuffer::from_host(&stream, &a_host)?;
let b_dev = DeviceBuffer::from_host(&stream, &b_host)?;
let mut c_dev = DeviceBuffer::<f32>::zeroed(&stream, N)?;
// SAFETY: this package owns the embedded device bundle produced for the
// kernels module above.
let module = unsafe { kernels::load(&ctx)? };
let prepared = module.prepare_vecadd(LaunchConfig1D::new((N as u32).div_ceil(256), 256, 0))?;
module.vecadd(&stream, &prepared, &a_dev, &b_dev, &mut c_dev)?;
let c_host = c_dev.to_host_vec(&stream)?;
println!("{:?}", &c_host[..5]);
Ok(())
}
Everything above the fn main runs on the GPU and everything below it runs on the CPU, in one file, in one language, built by one command. Notice that the ownership we learned in Part I has followed us across the bus: a_dev and b_dev are borrowed immutably by the launch while c_dev is borrowed mutably, and the DisjointSlice<f32> in the kernel signature is the device-side statement that no two threads will write the same element. The data race that is the traditional first lesson of GPU programming is here a borrow checkerborrow checker error.
Building it will take a moment the first time, because cargo oxide must build the codegen backend before it can build anything with it:
> root@machine ~/ $ cargo oxide run
Building rustc-codegen-cuda backend...
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1m 06s
✓ Backend built: .../librustc_codegen_cuda.so
Detected GPU arch: sm_80 (via nvidia-smi)
Finished `release` profile [optimized] target(s) in 22.04s
Running `target/release/borscht-cuda`
[0.0, 3.0, 6.0, 9.0, 12.0]
Four numbers added on a hundred billion transistors. It is not much, but every matrix multiplication in §4.4 is that same skeleton with a better kernel inside it, and it is worth knowing two more subcommands before we go there, since we will lean on both: cargo oxide inspect prints the PTX that came out the far end of the pipeline, and cargo oxide pipeline prints every intermediate along the way, from Rust MIR through dialect-mir and LLVM IR to PTX. When a kernel is slow or wrong, that is where you look.
GPU MODE Lecture 3: Getting Started With CUDA for Python Programmers
GPU MODE Lecture 5: Going Further with CUDA for Python Programmers
GPU MODE Lecture 2: Ch1-3 PMPP book
GPU MODE Lecture 4: Compute and Memory Basics (Ch4-5 PMPP)
#![allow(unused)]
fn main() {
enum Device {
Cpu,
Gpu
}
enum Dtype {
Float64(f64),
Float32(f32),
Float16(f16),
Bfloat16(bf16),
}
struct Tensor {
shape: Vec<u8>,
stride: Vec<u8>,
device: Device,
dtype: Dtype,
storage: Vec<Dtype>
}
}
2.3 From Performance Primitives to Linear Solvers
Table of Contents (§2. The Massively Parallel Multidimensional Tensor)
2.4 Accelerating Matrix Multiplication on GPUs
In which we …
4.3.1 From Multi Core CPUs to Many Core GPUs
#![allow(unused)]
fn main() {
// gpu_host.rs
use cudarc::{driver::{self, PushKernelArg}, nvrtc};
use src_device::T; // shared type with device code
static PTX: &str = include_str!(concat!(env!("OUT_DIR"), "/gpu_device.ptx")); // Embed the PTX code as a static string.
pub fn cudars_helloworld() -> Result<(), Box<dyn std::error::Error>> {
// initialize device context and stream via driver api
let process = driver::CudaContext::new(0)?; // device 0
let queue = process.default_stream();
// load ptx via nvrtc
let dylib = process.load_module(nvrtc::Ptx::from_src(PTX))?;
let add_kernel = dylib.load_function("add")?;
// allocate on device
let (a, b): ([T; _], [T; _]) = ([1.0, 2.0, 3.0, 4.0], [2.0, 3.0, 4.0, 4.0]);
let (a_gpu, b_gpu, mut c_gpu) = (queue.clone_htod(&a)?, queue.clone_htod(&b)?, queue.alloc_zeros::<T>(a.len())?);
let (a_len, b_len) = (a_gpu.len(), b_gpu.len());
let cfg = driver::LaunchConfig { grid_dim: (1, 1, 1), block_dim: (4, 1, 1), shared_mem_bytes: 0, };
unsafe {
queue
.launch_builder(&add_kernel).arg(&a_gpu).arg(&a_len).arg(&b_gpu).arg(&b_len).arg(&mut c_gpu)
.launch(cfg)?;
}
queue.synchronize()?;
let c = queue.clone_dtoh(&c_gpu)?;
println!("c from cuda is = {:?}", c);
Ok(())
}
}
#![allow(unused)]
fn main() {
// gpu_device.rs
use cuda_std::kernel;
use crate::T;
#[allow(improper_ctypes_definitions)]
#[kernel] pub unsafe fn add(a: &[T], b: &[T], c: *mut T) {
let i = cuda_std::thread::index_1d() as usize;
if i < a.len() {
let elem = unsafe { &mut *c.add(i) };
*elem = a[i] + b[i];
}
}
#[allow(improper_ctypes_definitions)]
#[kernel] pub unsafe fn saxpy(a: &[T], b: &[T], c: *mut T) {
let i = cuda_std::thread::index_1d() as usize;
todo!()
}
#[allow(improper_ctypes_definitions)]
#[kernel] pub unsafe fn smul(a: &[T], b: &[T], c: *mut T) {
let i = cuda_std::thread::index_1d() as usize;
todo!()
}
#[allow(improper_ctypes_definitions)]
#[kernel] pub unsafe fn stanh(a: &[T], b: &[T], c: *mut T) {
let i = cuda_std::thread::index_1d() as usize;
todo!()
}
}
4.3.2 From CUDA Rust to PTX
4.3.3 Accelerating SGEMV on sm80 (Ampere)
4.3.4 Accelerating SGEMM on sm90 (Hopper)
4.3.5 Accelerating SGEMM on sm90 (Hopper)
4.3.6 Accelerating SGEMM on sm100 (Blackwell)
Intermezzo Two: The Language of Numerical Analysis
Fixed-Size DataA modified excerpt from How to Design Programs (Felleisen et al. 2018)’s Intermezzo 1: Beginning Student Language §1. Self-Supervised Sequence Learning with Single-Layer Networks deals withBSLborscht.Tensoras if it were a natural language. It introduces the “basic words” of the language, suggests how to compose “words” into “sentences,” and appeals to your knowledge of algebra for an intuitive understanding of these “sentences.” While this kind of introduction works to some extent, truly effective communication requires some formal study.
In §2. The Massively Parallel Multidimensional Tensor,
you have implemented your first language models by using of the stochasticity from
probability theory with the goal of next-token prediction and the dimensionality from linear algebra with the specification of single-layer networks with the 
borscht.Tensor. While we’ve used such objects algebraically and computationally,
the producing such objects and their properties requires
precisely describing the definitional what
and rigorously proving the logical why.
In more highfalutin-speak, they are the highest standard when it comes to making ontological and epistemological statements about reality.
This is what is done in the formal treatment of mathematics, which as the opening quote suggests, is required for truly effective communication. Unlike the Intermezzo’s in HTDP which provide formal treatment of the primitives of software 1.0, we will do so with that of software 2.0.
linear algebra library is aspirational
“vectors” and “matrices”, but these are just rank 1 and rank 2 arrays. ignore a vector are coordinates of some basis, matrix is a linear map, which depends that the scalars are from a field (that is, addition is associative!). this goes away with IEEE754!
we still use the same notation. Householder convention. we say mathematical terms, but mean their computational representations.
Bibliography
Abelson, Harold, and Gerald Jay Sussman. 1996. Structure and Interpretation of Computer Programs. With Julie Sussman. 2nd ed. MIT Press. https://mitp-content-server.mit.edu/books/content/sectbyfn/books_pres_0/6515/sicp.zip/index.html.
Al-Faisal, Faisal, and Ty Ghaswala. 2026. “MATH 235: Linear Algebra 2 for Honours Mathematics.” Course notes, University of Waterloo. https://www.math.uwaterloo.ca/~f2alfais/notes/math235-notes.pdf.
Axler, Sheldon. 2026. Linear Algebra Done Right. https://linear.axler.net/LADR4e.pdf.
Chan, Stanley H. 2021. Introduction to Probability for Data Science. Michigan Publishing. https://probability4datascience.com/.
Cotterell, Ryan, Anej Svete, Clara Meister, Tianyu Liu, and Li Du. 2023. “Formal Aspects of Language Modeling.” 2311.04329V2. November 7. arXiv (2311.04329v2). https://arxiv.org/abs/2311.04329v2.
Felleisen, Matthias, Robert Bruce Findler, Matthew Flatt, and Shriram Krishnamurthi. 2018. How to Design Programs: An Introduction to Programming and Computing. 2nd ed. MIT Press. https://htdp.org/.
Fisler, Kathi, Shriram Krishnamurthi, Benjamin S. Lerner, and Joe Gibbs Politz. 2025. A Data-Centric Introduction to Computing. https://dcic-world.org/.
Forrest, Barbara, and Brian Forrest. 2020. “Math 147 Honours Calculus 1 Advanced Course Notes.” Course notes, University of Waterloo. https://www.math.uwaterloo.ca/~beforres/M147/CourseNotes/Forrest_M147CN_F20.pdf.
Jurafsky, Daniel, and James H. Martin. 2026. Speech and Language Processing. 3rd ed. draft. https://web.stanford.edu/~jurafsky/slp3/.
Lay, David C, Steven R Lay, and Judith Mcdonald. 2011. Linear Algebra and Its Applications. Pearson Education Limited.
Newstead, Clive. 2026. An Infinite Descent into Pure Mathematics. Last modified June 25, 2026. https://infinitedescent.org/.
Peter, Marc, Deisenroth Aldo, Faisal Cheng, and Soon Ong. 2020. Mathematics for Machine Learning. https://mml-book.github.io/book/mml-book.pdf
Stewart, James, Daniel Clegg, and Saleem Watson. 2020. Calculus: Early Transcendentals. 9th ed. Cengage Learning.
Stepanov, Alexander A, and Daniel E Rose. 2014. From Mathematics to Generic Programming. Addison-Wesley Professional.
Tao, Terence. 2022. Analysis I Fourth Edition. Springer Nature. https://terrytao.wordpress.com/books/analysis-i/
Labs
Lab 1: Elements of Tensors
Lab 3: Tensor++ and Matrix Decompositions

La perspective curieuse (Jean-François Niceron 1638)
- Extend
Tensorwhich we developed in Chapter 2.1 Virtualizing Shapes with Strides, Chapter 2.2 Accelerating Basic Linear Algebra on CPUs with native implementations of the operations from Lab 1 - Extend
borscht.linalg.solve()which we developed in Chapter 2.3 From Performance Primitives to Linear Solvers with matrix decompositions
Lab 4: Accelerating Matmul with Tiles and Layouts

La Perspective practique (Jean Du Breuil 1642)
- Extend
Tensor.__matmul__which we developed in Chapter 2.4 Accelerating Matrix Multiplication on GPUs with an alternatively accelerated matrix multiplication using cuteDSL
Lab 6: Vision Language Action Model
Lab 7: Custom Autograd Backwards for VLA

Pamphlet on the Turk (Joseph Friedrich von Racknitz 1789)
- Extend
Tensor.backward()which we developed in Chapter 4.1 Automatic Differentiation with accelerated VLA operations from Lab 6
Probability can be seen as array programming! Every valid distribution which is some function
Let us digress on two technical aspects of