Automatic differentiation: forward mode and reverse mode
Before this lesson: lesson 04 (the chain rule, VJPs, computational graphs and the two sweeps). Linear algebra lesson 15 gives a short first view of JVPs and VJPs; this lesson goes much deeper. Time: about 80 minutes, plus the notebook.
Why this matters #
- Training.
loss.backward()in PyTorch andjax.gradin JAX are reverse-mode AD. One call gives the gradient with respect to all parameters, for about 2–3 times the cost of the forward pass. - Memory. Activation memory during training is the tape of reverse mode. Activation (gradient) checkpointing, used to train large language models, is the memory–time trade-off of section 3.
- Forward mode has its own uses: sensitivities of a simulator with few parameters, and Hessian-vector products (forward mode applied to a gradient, lesson 08).
- Interviews and debugging. "How does autograd work?", "why does training use reverse mode?", "why do I run out of memory?" all have exact answers in terms of the two modes.
1. Predict first #
2. The picture #
Three ways to get a derivative.
| symbolic | numerical (finite differences) | automatic (AD) | |
|---|---|---|---|
| what it differentiates | a formula | a black-box function | a program, step by step |
| result | a new formula | an approximation | exact numbers (up to rounding) |
| main problem | the formula can grow very large | truncation and rounding errors | memory (reverse mode) |
| cost of a full gradient in | depends on the formula | or evaluations | about 2–3 evaluations (reverse mode) |
Symbolic differentiation (as in SymPy or Mathematica) works on formulas. Formulas can grow very large. Take the logistic map , starting from . The program for has 12 operations (3 per step). Written as one formula, has 38 operations, because each step copies the previous formula twice; SymPy's derivative of that formula, before simplification, has 264 operations. This growth is called expression swell (Baydin et al. 2018, Table 1).
Numerical differentiation treats as a black box and uses . It needs two evaluations per input, and the result has truncation and rounding errors (lesson 07). It is the standard tool to check a gradient, not to train with.
Automatic differentiation does not build a formula. It applies the chain rule of lesson 04 to the primitive operations, while the program runs, at the current numbers. So loops, branches and function calls are no problem: AD differentiates the steps that were actually executed.
Forward mode: values and tangents move together. Choose an input direction . Next to every value , carry its tangent : the rate at which changes when the input moves along . Each primitive updates the pair (value, tangent) with its local derivative. This is the forward sweep of lesson 04, run during the forward computation.
Press Next step and predict each blue number. Each step computes a value and its tangent together, from left to right. With the seed the output tangent is . Change the seed to and run again to get : one forward pass gives one directional derivative, so two inputs need two passes. With you get in one pass.
Dual numbers: the tangent as a second part of the number. Write a number with a tangent as , where is a symbol with (but ). Multiply two such numbers:
The part is exactly the product rule. If you evaluate a function at , the part of the result is . The figure runs small programs on dual numbers.
Press Next step and predict the blue ε part before it appears. The input is . Each line applies one rule (shown on the right) to dual numbers; removes the second-order term. At the end the ε part equals the derivative from the formula, to all printed digits. Try loop (Horner): the program runs a loop three times, and AD still gives the exact derivative.
What is in dual numbers ()?
. This is with , , : . squares the parts separately; forgets the seed .
Reverse mode: first record, then walk back. Reverse mode needs the adjoints . The adjoint of a node depends on its children, which come later in the program. So reverse mode cannot run during the forward computation. It works in two phases:
- Forward run. Run the program and record each primitive operation on a tape: which variables it used, and the values needed for its local derivatives.
- Backward run. Seed . Walk the tape from the end to the start. For each entry , add to the adjoint of each input .
Step through both phases. In the forward run, each step writes one line of the tape (the table): the operation and its value. In the backward run, the tape is read from the bottom up. Each step sends times the local derivative to the parents. The local derivatives use stored values: needs the value of from the forward run. Contributions to and add because both fan out.
Reverse mode walks back through the program after the forward run. What must the forward run keep for it?
A local derivative like or needs values from the forward run. So the tape keeps the operations and these values (for a network: the activations). Dense Jacobians are never formed: each operation has a rule that applies its transposed Jacobian to a vector.
3. Definitions and main results #
Let be computed by a program of primitive operations, as in lesson 04: inputs , intermediate variables in topological order, and outputs . Write .
- A Jacobian-vector product (JVP) is for a vector (an input direction, also called a tangent). With it is column of .
- A vector-Jacobian product (VJP) is (or ) for a vector (an output weighting, also called a cotangent). With it is row of .
Forward mode (tangent mode) #
Algorithm (forward mode). Input: and a tangent .
- Set and for the inputs.
- For each operation in program order, compute the value and the tangent .
- Return and .
Claim. , a JVP. Proof: this is the forward sweep of lesson 04, applied to each output : , which is row of times .
Step 2 needs only the parents of , which were just computed. So a tangent can be freed as soon as no later operation needs it: forward mode needs no tape.
Dual numbers #
A dual number is with real and the rule . The arithmetic follows from this one rule:
(For the quotient, multiply the claimed result by and check that you get .)
Theorem (dual numbers give derivatives). For every polynomial , .
Proof.
- For a power, the binomial theorem gives .
- Every term with or a higher power is 0. So .
- A polynomial is a sum of terms . Add the results of step 2: .
For other primitives we define , for example . This is consistent with composition: , which is the chain rule. So running a whole program on is exactly forward mode, and the part of the output is .
A dual number is not a small number. There is no step size, so there is no truncation error, unlike
finite differences. In code, a dual number is a pair (value, tangent) with overloaded operators
(__add__, __mul__, …). This technique is called operator overloading: you run your ordinary
code, and the new number type does the calculus (section 5).
Reverse mode (adjoint mode) #
Algorithm (reverse mode). Input: and a cotangent .
- Forward run. Evaluate the program. For each operation , push an entry on the tape: the index , the parents, and the values needed to compute .
- Seed. Set all adjoints to 0, then .
- Backward run. Pop the tape entries in reverse order. For entry and each parent : .
- Return the input adjoints .
Claim. The result is the VJP . Proof: define the scalar . Its gradient is (the chain rule for the linear map ). The backward sweep of lesson 04 for the scalar starts with and gives . The reverse order of the tape is a reverse topological order, so every adjoint is complete before it is used.
The += in step 3 is the fan-out rule: a variable used times gets contributions. Libraries
like micrograd and PyTorch implement this with a Value (or tensor) object that stores its data,
its parents and a small backward function; backward() sorts the graph topologically and calls the
backward functions in reverse order.
One product, two orders #
For a chain with , the Jacobian is . The two modes compute it in the two natural orders:
Every step is a matrix-vector product, never a matrix-matrix product. In fact the are never built: each primitive comes with a JVP rule () and a VJP rule (). For a dense layer both rules are one matrix-vector product, the same work as the layer itself.
Cost in time #
Write for the number of operations to evaluate .
| forward mode | reverse mode | |
|---|---|---|
| one pass computes | (one column for ) | (one row for ) |
| full Jacobian | passes | passes |
| time of one pass | ||
| extra memory | the current tangents only | the tape: all needed intermediate values |
Why one pass costs only a constant times . Each primitive has a local derivative that costs a small constant times the primitive itself: for , the tangent costs two multiplications and one addition; for , the derivative needs one . Summing over all operations gives a constant times . Griewank and Walther (2008) prove a bound with ; in practice is about 2 to 3 (as quoted by Baydin et al. 2018).
Consequences.
- The cheap gradient principle. For a scalar function (), one reverse pass gives the whole gradient for at most , independent of the number of inputs . Forward mode needs passes. This is why every neural network is trained with reverse mode.
- Few inputs, many outputs (): forward mode wins. Example: how a simulated field with values responds to 3 physical parameters needs 3 forward passes and no tape.
- Square maps (): the pass counts are equal, and forward mode often wins on memory.
One pass at a time. Press One pass: forward mode fills one column of the Jacobian per pass, reverse mode one row. For the simulator shape , forward mode needs 3 passes and reverse mode 12. Switch the shape to loss and count again. (This figure is from linear algebra lesson 15.)
You need the full Jacobian of a map . Which plan needs the fewest passes?
Forward mode gives one column per pass, and the Jacobian has 3 columns. Reverse mode gives one row per pass and would need 1000 passes. "Everything in one reverse pass" holds only for a scalar output ().
Cost in memory, and checkpointing #
In reverse mode, the backward run needs the stored values of the forward run. For a network with layers, the tape holds the input of every layer (the activations), so memory grows with depth and with batch size. Forward mode keeps only the current values and tangents.
Checkpointing trades memory for time. Number the layer inputs (layer needs in the backward run). Choose an interval .
- Forward run: keep only the checkpoints (about of them).
- Backward run, segment by segment from the end: recompute the activations of one segment from its checkpoint (at most values), run the backward steps of that segment, then free them.
Count. The peak memory is about checkpoints plus values of one segment: . The extra work is one recomputation of every non-checkpoint activation: fewer than layer evaluations, so at most one extra forward pass. is smallest where its derivative is 0, at , with instead of (Chen et al. 2016).
Press backward step several times. The forward run kept only the orange checkpoints. When the backward run enters a segment, it recomputes that segment's activations (blue), uses them, and frees them. Watch the count of stored activations. Then move the interval : the chart shows the peak memory and the recomputed layers for every ; the peak memory is lowest near .
Logarithmic memory: binomial checkpointing
Checkpointing can be applied recursively: inside a segment, place checkpoints again. Griewank's binomial checkpointing ("revolve", 1992) reverses a chain of steps with memory for time. It is used for long time-stepping simulations (adjoints of ODE and PDE solvers), where storing every step is impossible. Blondel and Roulet (2024, §8.5) describe recursive halving and the dynamic-programming schedule.
A network has layers. You checkpoint every layers. Compared with storing all activations, what do you get?
You keep 10 checkpoints and, during the backward run, at most one segment of 10 values; at the first segment processed, 9 earlier checkpoints are still stored, so the peak is . Each of the 90 non-checkpoint activations is recomputed once: 90 layer evaluations, less than one forward pass. Checkpointing never gives memory for free; "no extra work" is the tempting wrong answer.
Full Jacobians and the order of the product #
To build the whole Jacobian, you can run JVPs (columns) or VJPs (rows). With dense Jacobians, you can also multiply the matrices directly. Starting from the input side, step multiplies by the accumulated block; starting from the output side, the accumulated block by . The numbers of multiplications are
The prefactors and usually decide: start on the side with the small dimension. When a narrow layer sits in the middle, a mixed order can beat both ends.
Compare the three bars (log scale). For the loss shape , the output side needs multiplications and the input side : about 92 times more. Switch to simulator and the input side wins. Try bottleneck (): both ends cost about , but the best order needs only . Move the width sliders and predict which bar is shortest before you look.
The best order is a dynamic program
The cheapest bracketing of a matrix chain is found by the classic matrix-chain dynamic program in time: the best cost of is the minimum over the split point of the costs of the two parts plus the cost of their product. The figure uses it for the third bar. For general computational graphs (not chains), finding the cheapest way to accumulate the Jacobian is NP-complete (Naumann 2008), so tools use heuristics. In ML this rarely matters: training needs one VJP, and curvature methods need JVPs and VJPs of the gradient, not full Jacobians.
AD in practice #
The same two modes appear in every framework, under different names.
| what you write | what it does |
|---|---|
x.requires_grad_(True) (PyTorch) |
record operations on x on the tape |
y.backward() (PyTorch) |
reverse sweep with seed ; adds the result into x.grad |
y.backward(u) (PyTorch) |
reverse sweep with seed for a non-scalar : the VJP |
retain_graph=True (PyTorch) |
keep the tape after the backward run (it is freed by default, to save memory) |
with torch.no_grad(): |
do not record a tape (inference; saves memory) |
torch.func.jvp, jax.jvp |
forward mode: one JVP |
torch.func.vjp, jax.vjp |
reverse mode: returns a function |
jax.grad |
reverse mode for a scalar function |
jax.jacfwd, jax.jacrev |
full Jacobian from JVPs or VJPs (batched with vmap) |
torch.utils.checkpoint, jax.checkpoint |
checkpointing of a block |
Note that backward() adds into .grad. This is the fan-out rule again, applied across calls:
that is why a training loop calls optimizer.zero_grad() before each backward run.
Answers to section 1. (1) Forward mode needs passes, one per input direction. Reverse mode needs one pass, because the output is a scalar (); it costs a small constant (about 2–3) times one evaluation of the loss. (2) has the shape of , , and rank at most 1, because it is an outer product (lesson 04). It has rank exactly 1 when and .
4. Worked example #
(a) Both modes on at , written as the traces an AD tool produces (Baydin et al. 2018, Tables 2 and 3).
Forward mode, seed . Each line computes the value and the tangent:
| value | tangent |
|---|---|
Result: . A second pass with gives .
Reverse mode. The forward run writes the left column of the table above (values only) to the tape. Then the backward run reads the tape from the bottom:
| tape entry (read upward) | adjoint updates |
|---|---|
| ; ; | |
| ; | |
| ; | |
Result: both partial derivatives, , from one backward run. The stored values , and were needed: this is what the tape is for.
(b) A full Jacobian by JVPs and by VJPs. Let with
- Forward run: and .
- Local Jacobians: and .
- Two JVPs (forward mode, one per input):
- : , then : column 1.
- : , then : column 2.
- Three VJPs (reverse mode, one per output):
- : , then : row 1.
- : , then : row 2.
- : , then : row 3.
- Both give . Here , so forward mode needs fewer passes.
Check one entry directly: , so . ✓
(c) Dual numbers for at . Seed .
- .
- .
- .
Check: . ✓
5. In code #
A complete forward-mode engine (dual numbers) and a complete reverse-mode engine (a Value class
with a tape), in plain Python. Both reproduce the numbers of section 4. The notebook builds them step
by step.
import math
class Dual:
"""a + b·ε with ε² = 0. The ε part (b) carries the derivative."""
def __init__(self, a, b=0.0):
self.a, self.b = a, b
def __add__(self, o):
o = o if isinstance(o, Dual) else Dual(o)
return Dual(self.a + o.a, self.b + o.b)
__radd__ = __add__
def __sub__(self, o):
o = o if isinstance(o, Dual) else Dual(o)
return Dual(self.a - o.a, self.b - o.b)
def __mul__(self, o):
o = o if isinstance(o, Dual) else Dual(o)
return Dual(self.a * o.a, self.b * o.a + self.a * o.b) # ε² term dropped
__rmul__ = __mul__
def sin(d): return Dual(math.sin(d.a), math.cos(d.a) * d.b)
def log(d): return Dual(math.log(d.a), d.b / d.a)
def f(x1, x2): # ordinary code; works for Duals
return log(x1) + x1 * x2 - sin(x2)
y = f(Dual(2.0, 1.0), Dual(5.0, 0.0)) # seed: tangent e1
print(round(y.a, 3), round(y.b, 3)) # 11.652 5.5
y = f(Dual(2.0, 0.0), Dual(5.0, 1.0)) # seed: tangent e2
print(round(y.b, 3)) # 1.716
class Value:
"""A scalar that records how it was made (the tape) and can run the reverse sweep."""
def __init__(self, data, parents=(), local=()):
self.data, self.grad = data, 0.0
self.parents, self.local = parents, local # local[i] = d self / d parents[i]
def __add__(self, o): return Value(self.data + o.data, (self, o), (1.0, 1.0))
def __sub__(self, o): return Value(self.data - o.data, (self, o), (1.0, -1.0))
def __mul__(self, o): return Value(self.data * o.data, (self, o), (o.data, self.data))
def backward(self):
order, seen = [], set()
def visit(v): # topological order by depth-first search
if id(v) not in seen:
seen.add(id(v))
for p in v.parents: visit(p)
order.append(v)
visit(self)
self.grad = 1.0 # seed: d y / d y = 1
for v in reversed(order): # reverse topological order
for p, d in zip(v.parents, v.local):
p.grad += v.grad * d # add: fan-out sums
def vlog(v): return Value(math.log(v.data), (v,), (1.0 / v.data,))
def vsin(v): return Value(math.sin(v.data), (v,), (math.cos(v.data),))
x1, x2 = Value(2.0), Value(5.0)
y = vlog(x1) + x1 * x2 - vsin(x2)
y.backward()
print(round(y.data, 3), x1.grad, round(x2.grad, 3)) # 11.652 5.5 1.716
The Value objects form the tape: each one keeps its parents and its local derivatives, which use
the values of the forward run (o.data, self.data, cos(v.data)).
The same computations in PyTorch, including the full Jacobian of example (b) by 2 JVPs and by 3 VJPs:
import torch
from torch.func import jvp, vjp
x = torch.tensor([2.0, 5.0], dtype=torch.float64, requires_grad=True)
f = lambda x: torch.log(x[0]) + x[0] * x[1] - torch.sin(x[1])
y = f(x) # forward run: PyTorch records the graph (the tape)
y.backward() # reverse sweep with seed 1
print(x.grad) # tensor([5.5000, 1.7163], dtype=torch.float64)
v = torch.tensor([1.0, 2.0], dtype=torch.float64)
print(jvp(f, (x.detach(),), (v,))[1]) # forward mode, J v: tensor(8.9327, dtype=torch.float64)
F = lambda x: torch.stack([x[0] * x[1] + x[1] - x[0], x[0] * x[1] * (x[1] - x[0]), (x[1] - x[0]) ** 2])
x0 = torch.tensor([1.0, 2.0], dtype=torch.float64)
cols = [jvp(F, (x0,), (e,))[1] for e in torch.eye(2, dtype=torch.float64)]
_, pull = vjp(F, x0)
rows = [pull(e)[0] for e in torch.eye(3, dtype=torch.float64)]
print(torch.stack(cols, 1).tolist()) # [[1.0, 2.0], [0.0, 3.0], [-2.0, 2.0]]
print(torch.stack(rows, 0).tolist()) # [[1.0, 2.0], [0.0, 3.0], [-2.0, 2.0]]
In JAX the same calls are jax.grad(f)(x), jax.jvp(f, (x,), (v,)) and jax.vjp(f, x); with
jax.config.update("jax_enable_x64", True) they print the same numbers.
6. Where it shows up #
- Training neural networks. Every optimizer step is one reverse-mode pass: the cheap gradient principle makes billions of parameters affordable. Lesson 06 writes the backward pass of an MLP by hand; deep learning lesson 03 covers backpropagation in networks.
- Activation memory. The tape is the main memory cost of training large models. Activation
checkpointing (
torch.utils.checkpoint,jax.checkpoint), recomputation and reversible layers reduce it; see the ML systems track for memory budgets of real models. - Second derivatives. A Hessian-vector product is forward mode applied to the reverse-mode gradient ("forward-over-reverse") and costs a few gradients (lesson 08). Gradients through solvers and fixed points use implicit differentiation instead of a long tape (lesson 09).
- Probabilistic programming. Hamiltonian Monte Carlo (Stan, NumPyro, PyMC) needs the gradient of a log density at every step; it gets it from reverse-mode AD.
- ML for science. Differentiable simulators (fluid, climate, molecular dynamics) use reverse mode through the solver, which is the adjoint method of computational science; with many time steps, memory forces checkpointing. Forward mode gives the sensitivities of a simulation to a few physical parameters. See the ML for science track.
7. Common mistakes #
- "AD is symbolic differentiation" or "AD is finite differences". It is neither: it applies the chain rule to the executed operations at the current numbers. It builds no formula and uses no step size.
- Using forward mode for a loss with many parameters. It needs one pass per parameter. Use reverse mode for and forward mode when the number of inputs is small.
- Forgetting that
.gradaccumulates.backward()adds into.grad. Withoutoptimizer.zero_grad(), gradients of several steps are summed. - Keeping the tape alive by accident.
total_loss += losskeeps every graph in memory; usetotal_loss += loss.item()(a plain number) when you only want to log the value. - Calling
backward()on a vector output without a seed. For non-scalar , PyTorch needsy.backward(u): it computes the VJP , not the full Jacobian. - Trusting AD at a kink or across a branch. AD differentiates the branch that ran. The code
x if x != 0 else 0.0equals everywhere, but AD gives derivative 0 at . For ReLU at 0, frameworks return 0 by convention.
8. Check yourself #
- Compute with dual numbers, and say which derivative you get.
Answer
(all terms with vanish). The part is at . - A program computes . You need the full Jacobian. How many passes in each mode,
and which do you choose?
Answer
50 JVPs or 50 VJPs: the same number of passes. Choose forward mode: it needs no tape, so its memory does not grow with the length of the program. - Why can reverse mode not compute at the moment is computed in the forward run?
Answer
needs the adjoints of the children, which are computed later in the program. So reverse mode must finish the forward run first and keep (on the tape) the values that the local derivatives need. - A chain has widths . Count the multiplications to form
the full Jacobian from the input side and from the output side.
Answer
Input side: . Output side: . The input side (forward mode) is about 34 times cheaper. - A network has layers. With checkpointing, which interval minimizes peak memory, and
about how many activations are stored at the peak?
Answer
. The peak is about activations (exactly 15 with the count in the figure: 7 earlier checkpoints plus a segment of 8), instead of 64. The extra work is 56 recomputed layers, less than one forward pass. - In the
Valueclass, what goes wrong ifp.grad += v.grad * dis replaced byp.grad = v.grad * d?Answer
At a fan-out the second contribution overwrites the first. For the example, would get only (the path through , processed last) instead of , and only instead of .
What does one forward-mode pass compute?
A Jacobian-vector product : the directional derivative along the seed ; column of for .
What does one reverse-mode pass compute?
A vector-Jacobian product (or ); row of for , and the full gradient for a scalar output with .
How many passes give the full Jacobian of in each mode?
forward passes (one per input) or reverse passes (one per output).
Compute with .
: value and part .
State the rule that makes dual numbers compute derivatives.
, because removes all higher-order terms.
What does the tape of reverse mode store, and why?
The operations in order and the intermediate values their local derivatives need (for a network, the activations), because the backward run uses them after the forward run is finished.
What is the cheap gradient principle?
The gradient of a scalar function costs at most a small constant times one evaluation (typically 2–3), independent of the number of inputs, using one reverse pass.
When does forward mode beat reverse mode?
When there are few inputs and many outputs (), for example 3 simulator parameters and a large output field; also when memory is tight, because it needs no tape.
What does checkpointing trade, and what interval is best for layers?
It stores only every -th activation and recomputes the rest in the backward run. gives peak memory about for at most one extra forward pass.
Why does reverse mode use and not ?
A variable used several times (fan-out) receives one contribution from each use, and the contributions add.
9. Interview angle #
- "How does autograd work?" — The forward run records a graph (tape) of primitive operations and the
values they need.
backward()seeds and visits the nodes in reverse topological order, applying each operation's VJP rule and adding into the adjoints of its inputs. - "Why do we train with reverse mode?" — The loss is a scalar: one VJP gives the full gradient for about 2–3 forward passes. Forward mode needs one pass per parameter. The price is activation memory.
- "When would you use forward mode?" — Few inputs and many outputs; tight memory; JVPs inside Hessian-vector products (forward-over-reverse).
- "How do you reduce activation memory?" — Checkpointing (store every -th activation, recompute; ), smaller batches or micro-batching, mixed precision, reversible layers.
- "Implement a tiny autograd." — A
Valueclass with data, grad, parents and local derivatives; a topological sort; a reverse loop with+=. Then test it against finite differences.
10. Practice #
The notebook for this lesson runs in Colab. It has five exercises, each with a check cell and a folded solution:
- A
Dualclass with+,*,sin,exp,log; the derivative of a polynomial program with a loop. - A
Valueclass (tape, topological sort,backward()) with+,*,tanh,exp,log; the gradient of . jacobian_fwdandjacobian_rev: full Jacobians from the two engines, compared on a map .chain_flops(dims): the dense costs from the input side and the output side; find the cheaper end.- A checkpointing simulation: count stored activations and recomputations for every interval .
11. Watch and read #
- Baydin, Pearlmutter, Radul and Siskind, Automatic differentiation in machine learning: a survey, JMLR 2018: §2 (AD vs symbolic and numerical, expression swell), §3 (forward and reverse mode, costs, Tables 2–3).
- Blondel and Roulet, The Elements of Differentiable Programming, chapter 8: forward and reverse mode on chains and graphs, complexity, implementation, checkpointing (§8.5).
- Bright, Edelman and Johnson, Matrix Calculus (for Machine Learning and Beyond), chapter 8: dual numbers, AD on computational graphs, forward vs reverse mode.
- JAX documentation, The Autodiff Cookbook:
grad,jvp,vjp,jacfwd,jacrevand their costs. - PyTorch documentation, Autograd mechanics:
how the graph is recorded, saved tensors, and
no_grad. - Chen, Xu, Zhang and Guestrin, Training Deep Nets with Sublinear Memory Cost, 2016: checkpointing with memory.
- Karpathy, micrograd: a complete reverse-mode engine in about 100 lines.
Runs in your browser with nothing to install. The notebook repeats the key demos and has exercises with checks; solutions are inside, folded.
No Google account needed: Python runs inside your browser. The first start downloads about 40 MB; your changes are saved in this browser only.