There is a particular kind of bug that does not crash anything. Your code runs. The loss goes down. The training log scrolls past looking exactly like every training log you have ever seen. And at the end, the model hands you an answer that is completely, confidently wrong, with no error message anywhere to tell you so. This tutorial walks you into one of those on purpose. You will build linear regression out of nothing but raw numbers, no ready made layers and no ready made loss function, and you will get it working. Then you will change a single character, watch the model learn the wrong thing entirely, and understand exactly why. That second half is worth more than the first.
Before you begin
This is part two of a five part series on PyTorch foundations. Part one built a working network using ready made building blocks. This one takes those blocks away and rebuilds the smallest possible model out of raw numbers, so you can see what they were doing for you.
You need no maths beyond the equation of a straight line. Every result below, including the one where the model quietly learns the wrong thing, came from running the code on PyTorch 2.13.0.
Highlights
- Linear regression has two adjustable numbers, which makes it the smallest model that still trains through exactly the same mechanism as a network with billions of them.
- Broadcasting stretches mismatched tensors to fit rather than raising an error, so a shape mistake in PyTorch produces a wrong answer instead of a crash.
- A prediction shaped 100 subtracted from a target shaped 100 by 1 silently becomes a 100 by 100 grid, averaging the loss over ten thousand comparisons that were never meant to exist.
- A model that collapses to predicting the average of your data is showing the fingerprint of a shape bug rather than a learning rate problem.
- Every PyTorch abstraction wraps three operations, a forward pass, a loss, and a parameter update, and all three fit comfortably in ten lines of arithmetic.
- Gradient magnitude carries information, since a slope parameter receives a gradient five times larger than an intercept purely because of how each one affects the prediction.
- One assertion comparing prediction shape against target shape converts an entire category of silent wrong answers into a loud and immediate failure.
What Linear Regression Is, and Why It Is the Right Thing to Build First
Linear regression fits a straight line through a cloud of points. That is the whole model. It has exactly two adjustable numbers, a slope and an intercept, which is small enough that you can watch both of them change and reason about whether the change makes sense.
Every neural network is, in a meaningful sense, this same idea repeated and stacked. A single nn.Linear layer is doing precisely what you build below, just with more inputs and outputs. Understanding the two parameter version completely is worth more than half understanding the million parameter version.
The short version
Linear regression finds the straight line that sits closest to your data. It has two adjustable numbers, which makes it the smallest possible model that still trains exactly like a real one.
Why Build Something PyTorch Already Provides
You could write this model in four lines using nn.Linear and nn.MSELoss, and in real work you should. The reason to build it by hand once is diagnostic rather than practical.
When a model trains but produces poor results, the useful question is which stage is misbehaving. Engineers who have written a forward pass, a loss, and a parameter update themselves tend to reach for shape printing and gradient inspection. Engineers who have only ever assembled prebuilt layers tend to reach for hyperparameter tuning, which is usually the wrong tool and always the slower one.
The Failure This Tutorial Is Really About
There is a category of bug in numerical code that never raises an exception. The program runs, the loss decreases, the log looks ordinary, and the answer is wrong. These are far more expensive than crashes, because a crash tells you where to look and this tells you nothing.
PyTorch has a feature called broadcasting that quietly creates exactly this situation. Broadcasting lets you combine tensors of different shapes by automatically stretching the smaller one to fit. It is genuinely useful, and it is what lets you add one bias value to a hundred predictions without writing a loop.
The cost is that broadcasting will happily combine two shapes that were never meant to meet. A prediction shaped [100] and a target shaped [100, 1] are compatible under its rules, so instead of a shape error you get a [100, 100] grid comparing every prediction against every target. The loss is then an average over ten thousand meaningless pairs, and the only value that minimises it is the average of your data.
Did you know
The broken model in this article settles on 17.09 when the correct answer is 2.12. The number 17 is close to the mean of the data, which is the fingerprint of this failure. A model that has collapsed to predicting the average of everything is usually a shape problem rather than a learning rate problem.
What this tutorial assumes
- PyTorch 2.x is installed. Every output below came from version 2.13.0 running on CPU.
- You know what a straight line equation is. That is the entire mathematical prerequisite.
- You have seen a PyTorch training loop before, or you have read part one of this series, where the loop is introduced.
- You have about 15 minutes. Nothing here trains for longer than a second.
Run it in your browser
You do not have to install anything on your own machine. The JupyterLab playground gives you a browser based notebook environment running on a Linux host, so you can open a terminal inside it, install PyTorch with a single pip command, and work through every step of this tutorial in notebook cells. Sessions last one hour and extend by 15 minutes, which is comfortably longer than this tutorial needs. Install with %pip install --upgrade pip first, then %pip install torch numpy --index-url https://download.pytorch.org/whl/cpu. The percent sign matters because a notebook cell runs Python rather than shell, and the playground image ships a pip old enough to fail without the upgrade. Include numpy even though this tutorial never calls it, because torch prints a startup warning when it cannot find it.
Step 1: Generate Data With a Known Answer
Create 100 points along the line y equals 3x plus 2, then add random noise so the fit is not trivially exact.
import torch
torch.manual_seed(42)
X = torch.linspace(0, 10, 100).unsqueeze(1)
y = 3 * X + 2 + torch.randn(X.size()) * 1.5
print("X shape:", X.shape, "| y shape:", y.shape)X shape: torch.Size([100, 1]) | y shape: torch.Size([100, 1])
What this code does
torch.linspace(0, 10, 100)
Produces 100 evenly spaced values between 0 and 10. On its own it returns shape [100], a flat list.
.unsqueeze(1)
Adds a second dimension, turning [100] into [100, 1]. That is a column of 100 rows rather than a flat list of 100 numbers, and the distinction is the entire subject of this tutorial.
torch.randn(X.size()) * 1.5
Generates random noise the same shape as the data and scales it. Without noise the fit would be exact, which teaches you nothing about how the model copes with messy data.
Why generate data at all
You choose the line yourself, so you already know the correct answer. When the model returns something else, you know immediately that something is wrong rather than wondering whether the problem is simply hard.
torch.linspace(0, 10, 100) produces 100 evenly spaced values and returns shape [100]. The .unsqueeze(1) call adds a second dimension, turning it into [100, 1], which is a column of 100 rows rather than a flat list.
That distinction looks pedantic. It is the entire subject of this tutorial.
Did you know
PyTorch treats [100] and [100, 1] as genuinely different objects even though both hold 100 numbers. The first is a vector. The second is a matrix with one column. Arithmetic between them does not fail, it broadcasts, and that is why the bug in this article is silent.
Step 2: Create the Parameters to Learn
Linear regression has exactly two parameters: a slope and an intercept. Start both at zero and mark them as things PyTorch should track gradients for.
w = torch.zeros(1, 1, requires_grad=True)
b = torch.zeros(1, requires_grad=True)
What this code does
requires_grad=True
Tells PyTorch to record every operation these two numbers take part in, so it can later work out how to improve them. Tensors without this flag are treated as fixed data.
Starting both at zero
The model begins knowing nothing, predicting zero for every input. Watching it climb from there to the correct values is the whole demonstration.
Why w is shaped [1, 1] and not [1]
This keeps the multiplication in the next step producing a column rather than a flat row. Choosing [1] here is precisely the mistake explored later in this article.
requires_grad=True is the instruction that makes autograd record every operation these tensors take part in, so that gradients can be computed later. Tensors without that flag are treated as constant data.
Note the shape of w. It is [1, 1], not [1]. That choice is what keeps the matrix multiplication in the next step producing a column rather than a flat vector, and choosing [1] here is precisely the mistake explored further down.
Step 3: Write the Forward Pass and the Loss
No layers, no loss classes. Just arithmetic.
y_pred = X @ w + b
loss = ((y_pred - y) ** 2).mean()
print("y_pred shape:", y_pred.shape)
print("y shape :", y.shape)
print("difference :", (y_pred - y).shape)y_pred shape: torch.Size([100, 1])
y shape : torch.Size([100, 1])
difference : torch.Size([100, 1])
What this code does
X @ w
The @ symbol means matrix multiplication. Multiplying [100, 1] by [1, 1] gives [100, 1], which is one prediction per data point.
+ b
Adds the single bias value to all 100 predictions at once. PyTorch spreads it across every row for you rather than making you write a loop.
(y_pred - y) ** 2
Subtracts the correct answer from the prediction, then squares the result so that being wrong in either direction counts equally as error.
.mean()
Averages those 100 squared errors into a single number. That number is the loss, and this whole expression is what nn.MSELoss does internally.
The three printed shapes
All three read [100, 1]. That agreement is your proof the model is wired correctly, and checking it is a habit worth forming.
The @ operator is matrix multiplication. Multiplying [100, 1] by [1, 1] gives [100, 1], then adding b broadcasts the single bias value across all 100 rows, which is exactly what you want.
Below that, mean squared error is written out in full: subtract prediction from target, square the result so positive and negative errors both count as error, then average across all examples. That is all nn.MSELoss does.
Those three printed shapes are the assertion that the model is wired correctly. All three read [100, 1]. Get used to checking that.
Step 4: Look at the Gradients Before Training
Before running a loop, look at what backward() actually produces on a single call.
w2 = torch.zeros(1, 1, requires_grad=True)
b2 = torch.zeros(1, requires_grad=True)
loss2 = ((X @ w2 + b2 - y) ** 2).mean()
loss2.backward()
print(f"dL/dw = {w2.grad.item():.4f}")
print(f"dL/db = {b2.grad.item():.4f}")dL/dw = -221.7562
dL/db = -34.1793
What this code does
loss2.backward()
Asks PyTorch to work out, for each parameter, which direction would reduce the loss. It writes the answer into .grad on each one.
Both values are negative
A negative gradient means the loss falls if that parameter rises. Starting from zero the model predicts nothing while the real data climbs from 2 up to 32, so both parameters do need to increase.
Why dL/dw is so much larger
Changing the slope moves every prediction in proportion to its x value, while changing the intercept shifts everything by the same amount. Here the slope gradient is about six and a half times the intercept gradient, and a larger gradient means the optimizer moves that parameter further per step.
Both gradients are negative, which means the loss decreases if both parameters increase. That matches intuition: starting from w equals 0 and b equals 0, the model predicts zero everywhere while the real data climbs from 2 up to 32, so both parameters need to rise.
The magnitudes also tell you something useful. The gradient on w is roughly six and a half times the gradient on b, because changing the slope moves every prediction proportionally to its x value while changing the intercept shifts everything by the same amount. Larger gradient means the optimizer moves that parameter further per step.
Step 5: Write the Training Loop
Three operations repeated a thousand times.
lr = 0.01
for epoch in range(1, 1001):
y_pred = X @ w + b
loss = ((y_pred - y) ** 2).mean()
loss.backward()
with torch.no_grad():
w -= lr * w.grad
b -= lr * b.grad
w.grad.zero_()
b.grad.zero_()
if epoch == 1 or epoch % 200 == 0:
print(f"epoch {epoch:4d} | loss {loss.item():8.4f} | w {w.item():.4f} | b {b.item():.4f}")epoch 1 | loss 370.2874 | w 2.2176 | b 0.3418
epoch 200 | loss 2.2615 | w 3.0827 | b 1.5251
epoch 400 | loss 2.1795 | w 3.0250 | b 1.9089
epoch 600 | loss 2.1683 | w 3.0037 | b 2.0507
epoch 800 | loss 2.1668 | w 2.9958 | b 2.1031
epoch 1000 | loss 2.1666 | w 2.9929 | b 2.1225
What this code does
loss.backward()
Calculates which way each parameter should move. It does not move anything itself, it only writes down the direction.
with torch.no_grad()
Mandatory here. You are editing tensors PyTorch is watching, and this tells it the edit is bookkeeping rather than part of the model. Without it you get an error about in place operations.
w -= lr * w.grad
The actual update. Step against the gradient, scaled by the learning rate. This one line is what optimizer.step() does for you in a normal model.
w.grad.zero_()
Clears the gradient before the next round. PyTorch adds to it rather than replacing it, so without this you would soon be stepping on the sum of hundreds of gradients.
Why the answer is not exactly 3 and 2
The remaining gap is the noise you added at the start. A perfect recovery would actually mean the model had memorised the noise rather than found the underlying line.
The true values were w equals 3 and b equals 2. The model found 2.9929 and 2.1225. The remaining gap is the noise you added, and a perfect recovery would actually indicate the model had memorised the noise rather than found the underlying line.
Three details in that loop deserve attention.
with torch.no_grad() around the update is mandatory. You are modifying tensors that autograd is tracking. Without the context manager PyTorch tries to record the update itself as part of the graph, and raises a leaf Variable that requires grad is being used in an in-place operation.
Zeroing the gradients is not optional. PyTorch adds each new gradient to the existing value rather than replacing it. Skip w.grad.zero_() and by epoch 100 you are stepping on the sum of a hundred gradients.
The learning rate controls step size only. At 0.01 this problem converges comfortably. Raise it to 0.5 and the loss becomes nan within a few epochs as the parameters oscillate outward instead of settling.
Break It On Purpose
This is the part worth remembering. Change one character in the parameter definition and watch the model learn something completely different while giving no indication that anything is wrong.
Break it on purpose
Change w = torch.zeros(1, 1, requires_grad=True) to w = torch.zeros(1, requires_grad=True) and run the same loop. No error appears. Training completes normally.
epoch 2000 | loss 78.2313 | w 0.0001 | b 17.0888
The slope collapsed to zero and the intercept climbed to 17.09. The model gave up on the line entirely and learned to predict the average value of y, which for this data is close to 17.
Why it happens: with w shaped [1], the expression X @ w returns shape [100] instead of [100, 1]. Subtracting a [100, 1] target from a [100] prediction does not fail. It broadcasts into a [100, 100] matrix comparing every prediction against every target, so the loss is averaged over 10,000 mostly meaningless pairs, and the only value that minimises that quantity is the global mean.
Here is the mismatch made explicit:
w_bad = torch.zeros(1)
w_good = torch.zeros(1, 1)
print("bad :", (X @ w_bad).shape, "-", y.shape, "->", (X @ w_bad - y).shape)
print("good:", (X @ w_good).shape, "-", y.shape, "->", (X @ w_good - y).shape)bad : torch.Size([100]) - torch.Size([100, 1]) -> torch.Size([100, 100])
good: torch.Size([100, 1]) - torch.Size([100, 1]) -> torch.Size([100, 1])Broadcasting is a genuinely useful feature. It is what lets you add a single bias value to 100 predictions without writing a loop. The cost is that it will happily do something you did not intend and never mention it.
The defence is one line, and it belongs in every model you write:
assert y_pred.shape == y.shape, f"shape mismatch: {y_pred.shape} vs {y.shape}"The Same Model Using PyTorch Layers
Now that the mechanics are clear, here is the version you would actually write, which produces the same result.
from torch import nn
model = nn.Linear(1, 1)
loss_fn = nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
for epoch in range(1000):
loss = loss_fn(model(X), y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
print(f"w = {model.weight.item():.4f}, b = {model.bias.item():.4f}")
The abstractions are worth using. The point of writing the left column once is that when a model misbehaves, you know what the right column is doing.
Error Decoder
Each message below was produced by running deliberately broken code on PyTorch 2.13.0.
What This Teaches You About Bigger Models
The failure in this tutorial scales badly. In a two parameter model you noticed because you knew the answer. In a network with a hundred million parameters trained on data you have never plotted, a silent broadcasting bug looks like a model that underperforms slightly, and teams spend weeks tuning architecture and learning rates chasing a shape mismatch.
Three habits prevent it entirely.
Print shapes at every boundary while developing. Between data loading and the model, between the model and the loss, and inside custom layers. Delete the prints once it works.
Assert that prediction shape equals target shape. One line, no runtime cost worth mentioning, and it converts a silent wrong answer into a loud failure.
Sanity check on data with a known answer. Before training on real data, run the pipeline on something small where you know what should come out. If the model cannot recover a line you drew yourself, it will not do better on anything harder.
Conclusion
You built a working regression model from raw tensors, recovered parameters within 0.01 of the truth, and then watched the same code fail silently because of a single missing dimension. The second half is the valuable part.
The mechanics are simple enough to hold in your head: a forward pass is arithmetic on tensors, a loss is a single number summarising how wrong that arithmetic was, and training is repeatedly nudging parameters against their gradients. Every framework abstraction is a convenience over those three ideas.
What does not simplify is shape discipline. Broadcasting is a feature that will silently do the wrong thing on your behalf, and no amount of experience fully immunises you. The engineers who lose the least time to it are the ones who print shapes constantly and assert them at boundaries. Add that assert line to your next model and it will eventually save you a day.
FAQS
Q1: Why would I write linear regression by hand when nn.Linear exists?
For real work you should use nn.Linear, which is better tested, handles initialisation sensibly, and cannot produce the shape bug shown here. The value of writing it manually once is diagnostic rather than practical. When a model trains but produces poor results, the useful question is which stage is misbehaving, and that question is much easier to answer if you have implemented each stage yourself. Engineers who have written a forward pass, a loss, and a parameter update by hand tend to reach for shape printing and gradient inspection quickly, while those who have only ever composed prebuilt layers tend to reach for hyperparameter tuning first. It is a twenty minute investment that changes how you debug for years.
Q2: What exactly is broadcasting and when is it helpful rather than harmful?
Broadcasting is PyTorch automatically expanding a smaller tensor so that it matches the shape of a larger one during elementwise operations. It is genuinely useful most of the time. Adding a single bias value to a batch of 100 predictions works because the scalar broadcasts across all rows, and normalising an image by subtracting a per channel mean works the same way. The problem arises when two shapes are compatible under broadcasting rules but were never meant to interact, which is what happens with a [100] prediction and a [100, 1] target. PyTorch aligns them into a [100, 100] grid rather than raising an error, because from its perspective the request is perfectly valid. The rule to remember is that broadcasting compares dimensions from the right and expands any dimension of size one, so a missing dimension will be invented rather than rejected.
Q3: Do I need to understand the maths of gradient descent to use PyTorch effectively?
You need the intuition, not the derivations. The intuition is that a gradient tells you which direction increases the loss, so stepping in the opposite direction reduces it, and the learning rate controls how far you step. That is enough to reason about the failures you will actually encounter, such as loss becoming nan because the steps are too large or training stalling because they are too small. You never compute a derivative by hand in PyTorch, because autograd does it for you, which is the subject of the next article in this series. Deeper mathematical knowledge becomes valuable when you start designing custom loss functions or diagnosing unusual optimisation behaviour, but it is not what stands between you and a working model.
Q4: How do I know whether my model has converged or is just stuck?
Look at whether the loss is still changing meaningfully rather than at its absolute value. In the working example above, loss moved from 2.1683 at epoch 600 to 2.1666 at epoch 1000, a change small enough to call converged. The dangerous case is a loss that has flattened at a value which is not actually good, which is exactly the broken run in this article where loss settled at 78.23 and stayed there. Distinguishing the two requires a reference point, so wherever possible test your pipeline first on data where you know the correct answer. If the model cannot recover parameters you chose yourself, a flat loss curve means it is stuck rather than finished.
Q5: When should I use SGD rather than Adam as my optimizer?
Adam adapts the step size for each parameter individually, which usually means it converges faster and needs less learning rate tuning, so it is the sensible default when you are getting something working. Plain SGD applies the same learning rate everywhere, which makes it more predictable and, with momentum and a well tuned schedule, sometimes produces slightly better final results on large vision models. For the two parameter problem in this tutorial the choice is irrelevant since both converge in under a second. The practical advice is to start with Adam at a learning rate of 1e-3, get the model working, and only experiment with SGD if you are tuning a large training run where the last fraction of a percent matters.
Q6: How does this connect to real infrastructure and DevOps work?
Linear regression on synthetic data is a teaching exercise, but the shape discipline it teaches transfers directly to the models teams actually deploy, such as forecasting resource usage from historical telemetry or scoring incident severity from log features. The larger connection is operational: once a model works, someone has to version the training data, track which experiment produced which weights, package the model with pinned dependencies, serve it, and detect when live data drifts away from the training distribution. That practice is MLOps, and the beginner's guide to MLOps covers how the lifecycle fits together. For structured practice across the whole path from Python through to production machine learning, the AI learning path sequences the courses in a workable order.
Discussion