Insights & Use Cases
August 26, 2026

PyTorch crash course: tensors, autograd, and your first training loop

The PyTorch crash course, rewritten against PyTorch 2.13: tensors, autograd, a training loop, a CNN you can save and reload, and the 1.x idioms to stop copying.

Kelsey Foster
Growth
Reviewed by
No items found.
Table of contents

Our PyTorch crash course on YouTube has been watched close to 200,000 times. It walks through the whole framework in 50 minutes: tensors, autograd, a training loop, a neural network, a CNN. The concepts held up. The code didn't.

If you copy a 2022 PyTorch tutorial into a fresh environment today, you'll hit deprecation warnings on the first transform, an AttributeError on the first DataLoader iteration, and a pickle security warning when you load your saved weights. None of that is your fault — PyTorch moved.

So this is the crash course, rewritten against PyTorch 2.13 (the current stable release as of August 2026), with every API checked against the official docs and source. You'll go from an empty tensor to a trained convolutional network you can save, reload, and evaluate — and you'll know which idioms from older tutorials to stop copying.

Prerequisites: decent Python. You don't need to know how backpropagation works to follow along, though it helps. If you're still deciding between frameworks, our PyTorch vs TensorFlow comparison covers that question separately.

What changed since PyTorch 1.x

Start here, because these five changes account for almost every error you'll get running an older tutorial. Each one is a real deprecation or removal in current PyTorch, not a style preference.

Old tutorial code (PyTorch 1.x era) Current PyTorch 2.13 Why
transforms.ToTensor() v2.Compose([v2.ToImage(), v2.ToDtype(torch.float32, scale=True)]) torchvision's own source marks ToTensor as deprecated: "will be removed in a future release."
examples.next() next(examples) The DataLoader iterator's .next() method was removed. Only the Python builtin works.
torch.load(PATH) torch.load(PATH, weights_only=True) weights_only now defaults to True. Loading a checkpoint runs an unpickler, so untrusted files are a code-execution risk.
torch.device("cuda" if torch.cuda.is_available() else "cpu") torch.accelerator.current_accelerator() The torch.accelerator namespace covers CUDA, MPS, XPU, and MTIA with one call instead of hard-coding NVIDIA.
Variable(x, requires_grad=True) torch.tensor(x, requires_grad=True) Variable merged into Tensor back in 0.4 and has been a no-op wrapper ever since. If a tutorial imports it, the tutorial is old.

One addition rather than a replacement: torch.compile shipped with PyTorch 2.0 and is now the standard way to speed up a model. It's a one-line wrapper, covered at the end.

Install PyTorch and pick a device

Go to pytorch.org, select your OS, package manager, and compute platform, and run the command it generates. The install command changes with each CUDA release, which is why the site generates it rather than publishing a static pip install line.

If you'd rather not install anything, use a Colab notebook and set Runtime → Change runtime type → GPU. Everything below runs there unmodified.

Then pick a device once, at the top of your script, and reuse it:

import torch

device = torch.accelerator.current_accelerator().type 
if torch.accelerator.is_available() else "cpu"
print(f"Using {device} device")

That's the line the official PyTorch tutorials now use. It resolves to cuda on an NVIDIA box, mps on an Apple silicon Mac, and cpu everywhere else — no branching on vendor.

Tensor basics

Everything in PyTorch is a tensor operation. A tensor is a multi-dimensional array of elements of a single data type — think NumPy's ndarray, plus GPU support and gradient tracking.

import torch

x = torch.empty(2, 3)        # uninitialized memory, whatever was there
x = torch.rand(2, 3)         # uniform random in [0, 1)
x = torch.zeros(2, 3)
x = torch.ones(2, 3)

x = torch.rand(5, 3)
print(x.size())              # torch.Size([5, 3])
print(x.shape)               # same thing, as an attribute
print(x.shape[0])            # 5
print(x.dtype)               # torch.float32 by default

y = torch.ones(2, 2, dtype=torch.float16)
z = torch.tensor([2.5, 0.1])  # from a Python list

size() is a method, shape is an attribute, and they return the same thing. Default dtype is float32; pass dtype= when you want something else.

Every tensor also carries a requires_grad flag, False by default:

w = torch.ones(5, requires_grad=True)

Set it to True and PyTorch starts recording operations on that tensor so it can compute gradients later. You'll set it on every parameter you want to optimize. Hold that thought.

Operations, slicing, reshaping

x = torch.rand(2, 2)
y = torch.rand(2, 2)

z = x + y                    # element-wise
z = torch.add(x, y)          # same
y.add_(x)                    # trailing underscore = in-place, modifies y

z = x - y                    # or torch.sub
z = x * y                    # or torch.mul
z = x / y                    # or torch.div

The trailing-underscore convention is worth internalizing: add_, mul_, zero_ all mutate in place. It shows up constantly in real code.

x = torch.rand(5, 3)
print(x[:, 0])               # all rows, column 0
print(x[1, :])               # row 1, all columns
print(x[1, 1])               # a single element, still a tensor
print(x[1, 1].item())        # the Python float

x = torch.rand(4, 4)
print(x.view(16).size())     # torch.Size([16])
print(x.view(-1, 8).size())  # torch.Size([2, 8]) — -1 is inferred

.item() only works on a one-element tensor, and it's how you pull a loss value out for logging. view returns a new tensor sharing the same data; pass -1 for one dimension and PyTorch works it out from the total element count.

The NumPy bridge, and the gotcha

import numpy as np

a = torch.ones(5)
b = a.numpy()                # torch.Tensor -> np.ndarray

a.add_(1)
print(a)                     # tensor([2., 2., 2., 2., 2.])
print(b)                     # [2. 2. 2. 2. 2.]  <- changed too

On CPU, a and b share one memory location. Mutate either and both change. Going the other direction, the two constructors behave differently:

a = np.ones(5)
b = torch.from_numpy(a)      # shares memory with a
c = torch.tensor(a)          # copies

a += 1
print(b)                     # tensor([2., 2., 2., 2., 2.], dtype=torch.float64)
print(c)                     # tensor([1., 1., 1., 1., 1.], dtype=torch.float64)

This is a genuine source of silent bugs. from_numpy shares, torch.tensor copies.

Moving tensors to the accelerator

x = torch.rand(2, 2).to(device)          # create on CPU, then move
y = torch.rand(2, 2, device=device)      # create directly on the device

The second form is cheaper — it skips the CPU allocation and the copy. If you know a tensor belongs on the GPU, create it there.

Autograd: how gradients actually get computed

torch.autograd is PyTorch's automatic differentiation engine. It computes vector-Jacobian products, applying the chain rule for you. You don't need to derive anything by hand — but you do need to understand what it's tracking, because that's where the surprises live.

x = torch.randn(3, requires_grad=True)

y = x + 2
print(y)                     # note grad_fn=<AddBackward0>

z = y * y * 2
z = z.mean()
print(z)                     # grad_fn=<MeanBackward0>

print(x.grad)                # None — nothing computed yet
z.backward()                 # dz/dx
print(x.grad)                # now populated

Each operation on a tensor with requires_grad=True attaches a grad_fn — AddBackward0, MulBackward0, MeanBackward0 — building a computational graph as you go. Calling .backward() on the final scalar walks that graph in reverse and fills in .grad on every leaf.

In practice the final scalar is your loss: forward pass through the network, compute the loss, call loss.backward(), and every parameter now has a gradient. The same mechanism drives everything from this toy example up to training a speech recognition model in PyTorch from scratch.

Gradients accumulate — this will bite you

.backward() adds to .grad rather than replacing it. Run a training loop without clearing gradients and every step is polluted by the last one. So every loop needs an explicit reset:

optimizer.zero_grad()        # in the standard pipeline
weights.grad.zero_()         # if you're managing parameters yourself

One change from older tutorials: optimizer.zero_grad() now defaults to set_to_none=True, which sets gradients to None instead of writing zeros. It's faster and uses less memory. The practical consequence is that param.grad may be None rather than a zero tensor, so guard any code that inspects gradients directly.

Three ways to stop tracking

Sometimes you explicitly don't want operations recorded — during a weight update, or during evaluation.

a = torch.randn(2, 2, requires_grad=True)

# 1. Flip the flag in place
a.requires_grad_(False)

# 2. Detach into a new tensor that doesn't track
b = a.detach()

# 3. Wrap a block
with torch.no_grad():
    c = a + 2
    print(c.requires_grad)   # False

torch.no_grad() is the one you'll reach for most, and it's what wraps every evaluation loop below.

Linear regression, by hand

Before using PyTorch's building blocks, it's worth doing one training loop with nothing but autograd. The model is f(x) = w * x, and the function to approximate is f(x) = 2x, bias ignored.

import torch

X = torch.tensor([1, 2, 3, 4], dtype=torch.float32)
Y = torch.tensor([2, 4, 6, 8], dtype=torch.float32)

w = torch.tensor(0.0, dtype=torch.float32, requires_grad=True)

def forward(x):
    return w * x

def loss(y, y_pred):
    return ((y_pred - y) ** 2).mean()      # mean squared error

print(f"Prediction before training: f(5) = {forward(5).item():.3f}")

learning_rate = 0.01
n_iters = 100

for epoch in range(n_iters):
    y_pred = forward(X)
    l = loss(Y, y_pred)
    l.backward()                            # dl/dw lands in w.grad

    with torch.no_grad():                   # don't track the update itself
        w -= learning_rate * w.grad

    w.grad.zero_()                          # or the next step is corrupted

    if epoch % 10 == 0:
        print(f"epoch {epoch + 1}: w = {w.item():.3f}, loss = {l.item():.8f}")

print(f"Prediction after training: f(5) = {forward(5).item():.3f}")

Run it and w converges on 2.0 within the first couple dozen epochs, and f(5) lands on 10. Four things happened: forward pass, loss, backward pass, weight update. Every training loop in PyTorch is those four steps. The rest is replacing the hand-written parts with library code.

The same thing, the PyTorch way

Now swap the manual pieces for built-ins. The standard pipeline is:

  1. Design the model — input and output shapes, and the forward pass.
  2. Construct the loss and the optimizer.
  3. Loop: forward pass and loss, backward pass, update weights.
import torch
import torch.nn as nn

# PyTorch models expect a 2D tensor: (n_samples, n_features)
X = torch.tensor([[1], [2], [3], [4]], dtype=torch.float32)
Y = torch.tensor([[2], [4], [6], [8]], dtype=torch.float32)
X_test = torch.tensor([5], dtype=torch.float32)

n_samples, n_features = X.shape             # (4, 1)

class LinearRegression(nn.Module):
    def __init__(self, input_dim, output_dim):
        super().__init__()
        self.lin = nn.Linear(input_dim, output_dim)

    def forward(self, x):
        return self.lin(x)

model = LinearRegression(n_features, n_features)
print(f"Prediction before training: f(5) = {model(X_test).item():.3f}")

learning_rate = 0.01
n_epochs = 100

criterion = nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate)

for epoch in range(n_epochs):
    y_pred = model(X)                       # calls forward() for you
    loss = criterion(y_pred, Y)

    loss.backward()
    optimizer.step()                        # applies the update
    optimizer.zero_grad()                   # clears the gradients

    if (epoch + 1) % 10 == 0:
        w, b = model.parameters()
        print(f"epoch {epoch + 1}: w = {w[0][0].item():.3f}, loss = {loss.item():.8f}")

print(f"Prediction after training: f(5) = {model(X_test).item():.3f}")

Three things to notice. First, a PyTorch model is a class that inherits from nn.Module, defines its layers in __init__, and applies them in forward. Second, you call model(x), never model.forward(x) — the former runs hooks the latter skips. Third, nn.Linear is exactly the w * x + b you wrote by hand, and torch.optim.SGD is the update rule.

The prediction won't start at zero anymore, because nn.Linear initializes randomly rather than at 0. After 100 epochs it lands around 10.1.

If this boilerplate is the part you want to stop writing, PyTorch Lightning wraps the loop while keeping the model code identical. Learn the loop first, though — when something goes wrong at 2am, you'll be debugging these four lines.

Build A Real Model With This Loop

The same pipeline, applied to something harder: a CTC-based speech recognition model trained end to end on LibriSpeech in PyTorch.

Read the tutorial

Your first neural network

Same pipeline, bigger model: a fully connected network classifying MNIST digits. This section also brings in datasets, DataLoaders, transforms, GPU handling, and evaluation.

import torch
import torch.nn as nn
from torch.utils.data import DataLoader
from torchvision import datasets
from torchvision.transforms import v2
import matplotlib.pyplot as plt

device = torch.accelerator.current_accelerator().type if torch.accelerator.is_available() else "cpu"

# Hyperparameters
input_size = 784             # 28 * 28, flattened
hidden_size = 100
num_classes = 10
num_epochs = 2
batch_size = 64
learning_rate = 0.001

transform = v2.Compose([
    v2.ToImage(),
    v2.ToDtype(torch.float32, scale=True),
])

train_dataset = datasets.MNIST(root="./data", train=True, download=True,
transform=transform)
test_dataset = datasets.MNIST(root="./data", train=False, download=True, 
transform=transform)

train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)

That transform is the modernized line. The old transforms.ToTensor() still runs but emits a deprecation warning; v2.ToImage() followed by v2.ToDtype(torch.float32, scale=True) is the documented replacement and produces an equivalent result up to float precision.

A DataLoader gives you batching, shuffling, and multiprocess loading over a Dataset. To peek at one batch:

examples = iter(train_loader)
images, labels = next(examples)      # NOT examples.next() — that method is gone
print(images.shape, labels.shape)    # torch.Size([64, 1, 28, 28]) torch.Size([64])

for i in range(6):
    plt.subplot(2, 3, i + 1)
    plt.imshow(images[i][0], cmap="gray")
plt.show()

If you want to train on your own images instead, datasets.ImageFolder("path/to/dir", transform=transform) reads a directory tree where each subfolder is a class. Everything downstream is identical.

The model

class NeuralNet(nn.Module):
    def __init__(self, input_size, hidden_size, num_classes):
        super().__init__()
        self.l1 = nn.Linear(input_size, hidden_size)
        self.relu = nn.ReLU()
        self.l2 = nn.Linear(hidden_size, num_classes)

    def forward(self, x):
        out = self.l1(x)
        out = self.relu(out)
        out = self.l2(out)
        return out                   # raw logits, no softmax

model = NeuralNet(input_size, hidden_size, num_classes).to(device)

criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)

Two details that trip people up. Layer shapes have to chain: the first layer outputs hidden_size, so the second must accept hidden_size. And there's deliberately no softmax at the end — nn.CrossEntropyLoss applies log-softmax internally and expects raw logits. Add your own softmax and you'll train a subtly worse model with no error message. Check the docs for what a loss function expects; this is the single most common silent bug in beginner PyTorch code.

The training loop

n_total_steps = len(train_loader)

for epoch in range(num_epochs):
    model.train()
    for i, (images, labels) in enumerate(train_loader):
        images = images.reshape(-1, input_size).to(device)
        labels = labels.to(device)

        outputs = model(images)
        loss = criterion(outputs, labels)

        loss.backward()
        optimizer.step()
        optimizer.zero_grad()

        if (i + 1) % 100 == 0:
            print(f"epoch {epoch + 1}/{num_epochs}, step {i + 1}/{n_total_steps},
loss = {loss.item():.4f}")

Two nested loops: epochs on the outside, batches on the inside. That's the shape of essentially every supervised training loop you'll write.

Note .to(device) on both the images and the labels. If you move the model to the accelerator and forget the tensors, you get a device-mismatch RuntimeError. Model and data have to live in the same place. model.train() is a no-op for this architecture, but it's the right habit — it matters the moment you add dropout or batch norm.

Evaluation

model.eval()
with torch.no_grad():
    n_correct = 0
    n_samples = 0
    for images, labels in test_loader:
        images = images.reshape(-1, input_size).to(device)
        labels = labels.to(device)

        outputs = model(images)
        predicted = outputs.argmax(1)        # index of the highest logit

        n_samples += labels.shape[0]
        n_correct += (predicted == labels).sum().item()

    print(f"Accuracy on 10,000 test images: {100.0 * n_correct / n_samples:.2f}%")

Two epochs of this gets roughly 97% on MNIST. torch.no_grad() skips graph construction, which makes inference faster and lighter. outputs.argmax(1) replaces the older _, predicted = torch.max(outputs, 1) two-value unpack — both work, but argmax says what you mean when you only want the index.

A convolutional neural network

The MNIST model flattened each image into 784 numbers, throwing away every spatial relationship. Convolutional layers keep it. This section switches to CIFAR-10 — 32×32 color images across 10 classes — and adds convolutions, max pooling, and saving and loading.

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
from torchvision import datasets
from torchvision.transforms import v2

device = torch.accelerator.current_accelerator().type if torch.accelerator.is_available() else "cpu"

num_epochs = 5
batch_size = 4
learning_rate = 0.001

transform = v2.Compose([
    v2.ToImage(),
    v2.ToDtype(torch.float32, scale=True),
    v2.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)),
])

train_dataset = datasets.CIFAR10(root="./data", train=True, download=True, transform=transform)
test_dataset = datasets.CIFAR10(root="./data", train=False, download=True, transform=transform)

train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)

classes = ("plane", "car", "bird", "cat", "deer", "dog", "frog", "horse", "ship", "truck")

Two transforms this time, composed in order. ToDtype(scale=True) puts pixels in [0, 1]; Normalize with mean 0.5 and standard deviation 0.5 on all three color channels shifts that to [-1, 1].

class ConvNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(3, 32, 3)     # 3 input channels: R, G, B
        self.pool = nn.MaxPool2d(2, 2)
        self.conv2 = nn.Conv2d(32, 64, 3)
        self.conv3 = nn.Conv2d(64, 64, 3)
        self.fc1 = nn.Linear(64 * 4 * 4, 64)
        self.fc2 = nn.Linear(64, 10)         # 10 output classes, fixed

    def forward(self, x):
        # x: (N, 3, 32, 32)
        x = F.relu(self.conv1(x))            # (N, 32, 30, 30)
        x = self.pool(x)                     # (N, 32, 15, 15)
        x = F.relu(self.conv2(x))            # (N, 64, 13, 13)
        x = self.pool(x)                     # (N, 64, 6, 6)
        x = F.relu(self.conv3(x))            # (N, 64, 4, 4)
        x = torch.flatten(x, 1)              # (N, 1024)
        x = F.relu(self.fc1(x))
        x = self.fc2(x)
        return x

model = ConvNet().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate)

nn.Conv2d takes in-channels, out-channels, and kernel size. The first argument is 3 because color images have three channels; that one's fixed. The rest you can tune, with one hard constraint: each layer's output channel count must be the next layer's input count.

The number people get stuck on is 64 * 4 * 4. Where does it come from? A 3×3 convolution with no padding shaves 2 pixels off each spatial dimension; 2×2 max pooling halves them. So 32 → 30 → 15 → 13 → 6 → 4, with 64 channels at the end. Flattened, that's 1024 features.

You don't have to derive it. Drop print(x.shape) after each line in forward, run one batch, and read the shapes off. Do that once and the arithmetic stops being mysterious. If you'd rather see the shapes in a GUI, TorchStudio visualizes the graph as you build.

Note this model calls F.relu(...) directly in forward instead of declaring nn.ReLU() as a layer in __init__. Both are fine, and you'll see both in the wild. Activations have no parameters, so there's nothing to register — it's a style call.

n_total_steps = len(train_loader)

for epoch in range(num_epochs):
    model.train()
    running_loss = 0.0

    for images, labels in train_loader:
        images = images.to(device)
        labels = labels.to(device)

        outputs = model(images)
        loss = criterion(outputs, labels)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        running_loss += loss.item()

    print(f"epoch {epoch + 1}/{num_epochs}, loss = {running_loss / n_total_steps:.4f}")

The order of zero_grad, backward, and step is flexible — clearing before the backward pass works as well as clearing after the step. What matters is that gradients get cleared exactly once per iteration. running_loss divided by the step count gives a per-epoch average, which is a far more readable signal than the last batch's loss.

Five epochs gets you roughly 70% on CIFAR-10. Not impressive — CIFAR-10 is a much harder problem than MNIST, and this is a small network trained briefly. Train longer, add layers, tune the learning rate: it's a good architecture to experiment on.

Save and load your model

PATH = "cnn.pth"

# Save just the learned parameters, not the whole object
torch.save(model.state_dict(), PATH)

# To load, recreate the architecture first, then fill in the weights
loaded_model = ConvNet().to(device)
loaded_model.load_state_dict(torch.load(PATH, weights_only=True))
loaded_model.eval()

Saving the state_dict — a plain dictionary of parameter tensors — rather than the model object is the recommended approach. It's why you have to instantiate ConvNet() before loading: the class defines the structure, the file only supplies the numbers. Note that load_state_dict takes the loaded object, not the path.

The weights_only=True argument is the important 2026 change. torch.load runs an unpickler, and unpickling arbitrary files can execute arbitrary code. Current PyTorch defaults weights_only to True, restricting deserialization to tensor data. Pass it explicitly anyway — it documents the intent and keeps the code correct across versions. The official docs put it plainly: never load data from an untrusted source.

model.eval() switches layers like dropout and batch norm into inference behavior. This model has neither, so it changes nothing here — but forgetting it on a model that does have them produces inconsistent predictions, which is a miserable bug to chase. Call it before every evaluation.

Evaluate the loaded model with the same torch.no_grad() block from earlier and you'll get identical accuracy, which is the point of the exercise.

One line to make it faster: torch.compile

This didn't exist when the video was recorded. torch.compile arrived in PyTorch 2.0 and traces your model into an optimized kernel graph:

model = ConvNet().to(device)
model = torch.compile(model)

Everything else stays the same. The first iteration pays a compilation cost, then subsequent steps run faster — how much depends on the model and hardware. On a network this small the win is modest, and it's not worth the wait; on anything real, it's free performance for one line. Wrap the model after moving it to the device, and know that dynamic control flow in forward can trigger recompilation.

Hear What A Trained Model Sounds Like

Drop in an audio file and see what a production speech model returns — accuracy, timings, and confidence scores. No training run, no setup.

Try playground

When to train your own audio model — and when not to

We publish this crash course because plenty of the people who use our API also train their own models, and because the training loop above is genuinely the foundation for anything you'd build on audio. But it's worth being straight about where the line falls, since "I can write a training loop now" and "I should train my own production speech model" are very different claims.

Train your own when the model is the product. You're researching a new architecture, you have a task nobody sells an API for, you need to run fully offline on a device, or you have proprietary labeled data that gives you an accuracy edge nobody else can match. That's real, and PyTorch is the right tool.

Call an API when the model is a component. If you need automatic speech recognition so your product can do something with the text, training your own is usually a bad trade. Competitive speech-to-text means tens of thousands of labeled hours, a GPU cluster, and a team that keeps evaluating the model against real audio forever — accents, crosstalk, phone codecs, background noise. Fine-tuning a pretrained checkpoint gets you further than training from scratch, but it doesn't remove the ongoing cost.

Veed put the tradeoff about as clearly as anyone:

Assembly allowed our team to focus on what they are best at: Building a collaborative, browser-based video editor and distributing that product at speed and at velocity to our user base.

— Sabba Keynejad and Tim Mamedov, Veed

For reference on the other side of the build-versus-buy math: our Speech-to-Text API runs Universal-3.5 Pro at $0.21 per hour of audio for pre-recorded files, with streaming transcription for live audio. The call itself is about as long as the tensor examples above:

import os

from assemblyai.prerecorded.v2 import Transcriber

transcriber = Transcriber(api_key=os.environ["ASSEMBLYAI_API_KEY"])

transcript = transcriber.transcribe("https://assembly.ai/wildfires.mp3")
print(transcript.text)

Neither answer is universally right. But the honest version is that most teams building a product with speech in it should spend their PyTorch skills on the part that's differentiated, and treat transcription as infrastructure.

Where to go next

You now have the whole loop: tensors, autograd, nn.Module, loss, optimizer, training, evaluation, and checkpointing. That's the reusable part. Almost everything else in deep learning is a variation on it.

Three good next moves. Push the CIFAR-10 model further — more epochs, more layers, a learning rate schedule — since improving a model you already understand teaches more than reading about a new one. Then try fine-tuning a pretrained transformer, because in 2026 you'll fine-tune far more often than you'll train from scratch. And if audio is your domain, our Python speech recognition overview maps the landscape of libraries and APIs.

One habit worth keeping from this rewrite: when a tutorial's code doesn't run, check the version it was written against before you assume you broke something. PyTorch's API is stable, but four years is four years.

Skip The Training Run

If your project needs accurate transcription rather than a custom model, get it in a few lines of Python. Free API key, no commitment, pay-as-you-go after that.

Sign up free

Frequently asked questions

Is PyTorch difficult to learn?

PyTorch is one of the more approachable deep learning frameworks if you already know Python, because models are ordinary Python classes and the training loop is an ordinary for loop with no hidden abstraction. The concepts that take real effort are the ones underneath it — gradients, layer shapes, and why a loss stops decreasing — not the API. Most developers with solid Python can work through a crash course like this one in an afternoon.

Is PyTorch still relevant in 2026?

Yes — PyTorch remains the dominant framework for deep learning research and is now well established in production too. The current stable release is 2.13, and the 2.x line added torch.compile for graph-level optimization plus the vendor-neutral torch.accelerator API for CUDA, MPS, XPU, and MTIA devices. The tradeoffs against TensorFlow and JAX have shifted since 2022, mostly in PyTorch's favor on model availability and ecosystem.

How long will it take to learn PyTorch?

Getting to a working training loop takes a day or two if you know Python; getting comfortable takes a few weeks of actually building things. The fundamentals are small — tensors, autograd, nn.Module, an optimizer, and the four-step loop — and this crash course covers all of them. The longer part of the curve is debugging real models: shape mismatches, device mismatches, and losses that plateau.

What is the best way to learn PyTorch?

Write the code, don't just read it. Work through a crash course end to end, then immediately modify what you built — change the architecture, swap the dataset, break something on purpose and fix it. Pair that with the official PyTorch tutorials as a reference, and prefer material written against the version you've installed, since 1.x-era tutorials now use several deprecated or removed APIs.

Is PyTorch written in C or C++?

PyTorch has a Python front end backed by a C++ core called ATen, with CUDA kernels for GPU execution. That's why tensor operations are fast despite being called from Python: the Python layer dispatches into compiled code. There's also a standalone C++ API, LibTorch, for deploying models without a Python runtime.

What's the difference between training a model in PyTorch and using a speech-to-text API?

Training your own model in PyTorch means you own the architecture, the labeled data, the compute, and the ongoing evaluation — worth it when the model itself is your product or when you need to run fully offline. Calling a hosted speech-to-text API means one HTTP request against a model someone else trains, benchmarks, and maintains, priced per hour of audio. Most teams building a product that happens to need transcription are better served by the API, and should spend their deep learning effort on whatever is actually differentiated about their product.

Title goes here

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.

Button Text
PyTorch