Index Ai Academy
Phase 4 · Deep learning · Tooling

PyTorch tensors

NumPy-like arrays that run on GPUs.

About 30 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

PyTorch is the library most deep learning is written in. Most open LLMs use it, and so does the code behind most fine-tuning tools. Its core object is the tensor. A tensor is a NumPy array with two extra abilities: it can live on a GPU, and it can track gradients. You already know arrays, so this lesson is mostly translation. You'll also pick up three habits: checking shapes, dtypes and devices. Nearly every PyTorch bug report starts with one of those three.

What you'll be able to do

  • Create PyTorch tensors and read their shape, dtype and device
  • Translate NumPy habits to PyTorch: operations, broadcasting, indexing, dim vs axis
  • Reshape tensors with view, reshape, transpose and unsqueeze
  • Explain what moving a tensor to a GPU means, and why dtypes matter there

What it covers

  1. A new box for numbers you already know
  2. The same operations, with dim instead of axis
  3. Reshaping
  4. The shape inside a language model
  5. Worked example: a tiny batch of messages
  6. Devices and dtypes

A new box for numbers you already know

In the NumPy and tensors lessons, you stored numbers in arrays. A list of prices is one row. A spreadsheet of prices is a table. A stack of spreadsheets, one per store, is a 3-D block. A tensor is PyTorch's name for that same thing: an array with any number of dimensions.

So why a new library? Think of two kitchens. NumPy is a good home kitchen. PyTorch is the same kitchen, with two extras for running a restaurant:

  • A second, faster stove. A tensor can move to a GPU, the graphics chip that does thousands of sums at once.
  • A notebook of every step. A tensor can record the calculations done with it. That's how PyTorch works out gradients for you, which is the next lesson.

This lesson covers the first part: making tensors and moving their numbers around.

PyTorch in this course

The code cells below use import torch. Real PyTorch can't run in a browser. So here, torch is a small teaching subset of PyTorch, built on NumPy. It has the same names and behavior for everything the lessons use. It runs on the CPU only, and it's much slower. Its random numbers also differ from real PyTorch's, so your own machine will print different random values. Code you write here runs unchanged on your own machine after pip install torch.

Creating tensors

Every tensor carries three labels. You'll check them all the time:

  • shape: how big each dimension is. A table with 2 rows and 3 columns has shape (2, 3).
  • dtype: short for "data type". It says what kind of number is stored, such as whole numbers or decimals, and how many bits each one uses.
  • device: where the numbers live. That's cpu (the computer's main chip) or a GPU.

Run this and look at each label:

import torch

a = torch.tensor([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
print(a)
print("shape:", a.shape, "  dtype:", a.dtype, "  device:", a.device)

print(torch.zeros(2, 3))
print(torch.arange(5))                  # integers default to int64
torch.manual_seed(0)
print(torch.randn(2, 2))                # standard normal, float32
print(torch.tensor([1, 2, 3]).dtype, torch.tensor([1.0, 2.0]).dtype)
It runs right here in your browser, with nothing to install.

Here's what each line did:

  • torch.tensor(...) turned a nested list into a tensor. Its shape is torch.Size([2, 3]). A torch.Size behaves like a tuple, so a.shape[0] is 2.
  • torch.zeros(2, 3) made a 2 by 3 table of zeros.
  • torch.arange(5) counted from 0 to 4, like Python's range.
  • torch.randn(2, 2) drew random numbers from the bell curve, centered on 0. Setting the seed first with manual_seed makes the "random" numbers the same on every run.
  • The last line shows the default dtypes: int64 for whole numbers and float32 for decimals.

A float is a number with a decimal point. The number after it counts bits, the 0s and 1s used to store each value. Eight bits make one byte. So a float32 takes 4 bytes, and a float64 takes 8 bytes.

NumPy uses float64 by default. PyTorch uses float32: half the memory, and the size GPUs are built for. Think of writing prices to 7 digits instead of 16. For a model's weights, 7 digits is plenty.

In plain words: a tensor is an array plus three labels. Shape is its size, dtype is the kind of number, and device is where it lives.

Quick check
What does this print?
import torch
print(torch.tensor([7, 8, 9]).dtype)
  1. torch.int64
  2. torch.float32
  3. torch.int32
  4. torch.float64
Show the answer

torch.int64

The list holds whole numbers with no decimal points, so PyTorch uses its default integer type, torch.int64. Write 7.0 instead of 7 and you'd get float32.

The same operations, with dim instead of axis

Most of what you did with NumPy arrays works the same way on tensors. Run this and compare each line with what NumPy would do:

import numpy as np
import torch

x = torch.tensor([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
print(x * 2 + 1)                        # element-wise, like NumPy
print(x @ x.T)                          # matrix multiply: (2, 3) @ (3, 2)
print(x.sum(dim=0), x.mean(dim=1))      # reductions take dim= (NumPy: axis=)
print(x.max(dim=1))                     # max over a dim returns values AND indices
print(x + torch.tensor([10.0, 20.0, 30.0]))   # broadcasting works the same way
print(x[:, 1], x[x > 3])                # slicing and boolean masks too

n = x.numpy()                           # to NumPy (shares memory)...
t = torch.from_numpy(np.ones(3))        # ...and back
print(type(n).__name__, t.dtype)

Line by line:

  • x * 2 + 1 works on every number on its own. 1 becomes 3, 2 becomes 5, and so on.
  • x @ x.T is matrix multiplication. x.T is x flipped, so its shape is (3, 2). A (2, 3) times a (3, 2) gives a (2, 2) result. The top-left number is 1×1 + 2×2 + 3×3 = 14.
  • x.sum(dim=0) adds down each column: 1 + 4 = 5, 2 + 5 = 7, 3 + 6 = 9.
  • x.mean(dim=1) averages along each row: the mean of 1, 2, 3 is 2, and of 4, 5, 6 is 5.
  • x.max(dim=1) finds the biggest number in each row. It returns two things: the values (3 and 6), and the positions where they sit (both at index 2).
  • Adding a 3-number tensor adds it to every row. That's broadcasting, as in NumPy.
  • x[:, 1] takes column 1 from every row. x[x > 3] keeps only the numbers above 3.

The word reduction means any operation that shrinks a dimension away, such as sum, mean or max.

In plain words: dim names the dimension you squash. dim=0 squashes the rows, so you get one answer per column. dim=1 squashes the columns, so you get one answer per row.

Two differences catch people. First, reductions use dim where NumPy uses axis. Second, max(dim=...) returns a pair (values, indices), not only the values. The indices are useful: they tell you which item scored highest, such as a model's top-scoring answer.

The last three lines move data between the two libraries. x.numpy() turns a tensor into a NumPy array. torch.from_numpy goes the other way. On the CPU, both share memory: they're two views of the same numbers, so changing one changes the other. Notice that the new tensor kept NumPy's dtype, float64. PyTorch doesn't quietly convert it.

Quick check
x has shape (8, 5). What does x.sum(dim=0) return?
  1. A tensor of shape (5,): one sum per column
  2. A tensor of shape (8, 5)
  3. A tensor of shape (8,)
  4. A single number
Show the answer

A tensor of shape (5,): one sum per column

Reducing over dim 0 collapses the rows, leaving one value per column. There are 5 columns, so you get 5 sums.

Reshaping

Neural-network code spends a lot of time moving dimensions around. Picture an egg carton of 24 eggs. You can see it as 2 rows of 12, or 4 rows of 6. The eggs don't move; only the way you group them changes. Reshaping a tensor works the same way. Here are the common moves:

import torch

x = torch.arange(24)
print(x.view(2, 3, 4).shape)            # same 24 numbers seen as 2 × 3 × 4
print(x.view(4, -1).shape)              # -1: "work this one out"
b = x.view(2, 3, 4)
print(b.transpose(1, 2).shape)          # swap two dims: (2, 4, 3)
print(b.permute(2, 0, 1).shape)         # reorder all dims: (4, 2, 3)
print(b[0].unsqueeze(0).shape)          # add a dim of size 1: (1, 3, 4)
print(b.flatten(1).shape)               # merge everything after dim 0: (2, 12)

batch = torch.randn(32, 10, 64)         # (batch, sequence, features): the shape LLMs pass around
print((batch @ torch.randn(64, 16)).shape)   # matmul applies to the last dim

What each move does:

  • view(2, 3, 4) regroups the same 24 numbers into 2 blocks of 3 rows of 4. The sizes must multiply to the total: 2 × 3 × 4 = 24.
  • view(4, -1) asks for 4 rows. The -1 means "work this one out". 24 ÷ 4 = 6, so the shape is (4, 6).
  • transpose(1, 2) swaps two dimensions. (2, 3, 4) becomes (2, 4, 3).
  • permute(2, 0, 1) lists a new order for all the dimensions. Old dim 2 (size 4) goes first, then old dim 0 (size 2), then old dim 1 (size 3). That gives (4, 2, 3).
  • unsqueeze(0) adds a new dimension of size 1 at the front. It turns one example into a "batch of one", which models expect.
  • flatten(1) keeps dim 0 and merges everything after it. 3 × 4 = 12, so (2, 3, 4) becomes (2, 12).

In plain words: view and flatten regroup the numbers. transpose and permute change the order of the dimensions. unsqueeze adds a size-1 dimension. None of them change how many numbers there are.

Quick check
A tensor holds 24 numbers. Which of these reshapes fails?
  1. view(2, 12)
  2. view(4, 6)
  3. view(5, 5)
  4. view(24)
Show the answer

view(5, 5)

view only regroups numbers. The sizes must multiply to 24, and 5 × 5 is 25.

Go deeper view versus transpose under the hood

A tensor's numbers sit in memory in one long line. view gives you a new way to read that same line. Nothing is copied, so changing the view changes the original.

transpose also avoids copying. Instead, it changes the order in which PyTorch walks along the line. After a transpose, the numbers are no longer in plain row-by-row order. Real PyTorch calls this "not contiguous". Calling view on such a tensor can raise an error. Use reshape instead: it acts like view when it can, and copies the numbers when it must. Calling .contiguous() first also works, since it makes a row-by-row copy.

The shape inside a language model

The last two lines show the layout you'll see most: (batch, sequence length, features).

  • batch: how many examples go through together. Here, 32 sequences of text.
  • sequence length: how many tokens each sequence has. Here, 10.
  • features: how many numbers describe each token. Here, 64.

So the model keeps one row of 64 features per token, and one sequence per batch item.

Multiplying by a 2-D weight with shape (64, 16) acts on the last dimension only. Every token's 64 features become 16 new features. PyTorch does this for all 32 × 10 = 320 tokens at once. The result has shape (32, 10, 16).

Try it now

Read shapes from the outside in: batch, position, features. Most tensor bugs are a dimension in the wrong slot.

Quick check
What does this print?
import torch
x = torch.arange(12)
print(x.view(3, -1).shape)
  1. torch.Size([3, 12])
  2. torch.Size([3, 4])
  3. torch.Size([4, 3])
  4. torch.Size([3, 1])
Show the answer

torch.Size([3, 4])

There are 12 numbers. You asked for 3 rows, and -1 means work out the rest: 12 ÷ 3 = 4. So the shape is (3, 4).

Worked example

Worked example: a tiny batch of messages

Here are 2 short messages, 3 tokens each, with 4 features per token. The numbers are 0 to 23 so you can check them by hand. The code averages each message's tokens, then multiplies by a small weight of all ones.

import torch

h = torch.arange(24.0).view(2, 3, 4)   # 2 messages, 3 tokens each, 4 features per token
print(h.shape)
print(h[0])                            # message 0: one row per token
print(h.mean(dim=1))                   # average the tokens: one row per message
w = torch.ones(4, 2)                   # a tiny weight: 4 features in, 2 out
out = h @ w
print(out.shape)
print(out[0])
In the lesson you can change this and run it.

Message 0 has the tokens [0, 1, 2, 3], [4, 5, 6, 7] and [8, 9, 10, 11]. Averaging over dim 1 (the tokens) averages each column. For 0, 4 and 8 that's 4, so you get [4, 5, 6, 7]. Message 1 gives [16, 17, 18, 19]. The shape went from (2, 3, 4) to (2, 4). Next, each output of the all-ones weight adds up a token's 4 features. In message 0, the first token gives 0 + 1 + 2 + 3 = 6, the second gives 22, and the third gives 38. Each token went from 4 features to 2, so the shape is (2, 3, 2).

Devices and dtypes

A GPU is like a kitchen with a thousand cooks. Each cook is slow, but they all chop at once. A neural network is mostly the same simple sums repeated millions of times, so a GPU suits it well.

On a machine with an NVIDIA GPU, moving work there is one call. (Those GPUs go by the name "cuda", after NVIDIA's software.)

device = "cuda" if torch.cuda.is_available() else "cpu"
x = torch.randn(1000, 1000, device=device)     # created on the GPU
model = model.to(device)                       # move a model's weights
y = model(x)                                   # runs on the GPU; inputs must be on the same device

The first line picks the GPU if there is one, and the CPU otherwise. The second creates a tensor directly on that device. The third moves a model's weights there with .to(device). The fourth runs the model, and it works because x and the weights now sit on the same device.

Here's the catch. Tensors on different devices can't be combined. It's like asking one cook to mix two bowls that sit in different kitchens. Try it and you get the classic error "Expected all tensors to be on the same device". The fix is to move the inputs (or the model) with .to(device) so they match.

On a Mac with Apple silicon, the device is "mps" instead of "cuda".

Dtypes matter most on a GPU, because GPU memory is limited and costly. Lower-precision dtypes, float16 and bfloat16, use 2 bytes per number. That halves memory again compared with float32. Large models are trained and served this way.

Some quick sums show why. A model with 7 billion weights needs 7 billion × 4 bytes = 28 GB in float32. In bfloat16 it needs 7 billion × 2 bytes = 14 GB. The GPUs lesson covers the rest.

In this browser subset, everything stays on the CPU. torch.cuda.is_available() returns False.

Go deeper float16 versus bfloat16

Both use 16 bits per number, but they split those bits differently. Some bits store the digits, and some store how big or small the number is.

float16 keeps more digits but has a small range. Its largest value is 65,504, so big numbers during training can overflow. bfloat16 keeps the same range as float32 but fewer digits. Losing a little precision is usually fine for neural networks, and overflow is a bigger risk. That's why bfloat16 is popular for training.

Quick check
A tensor holds 1,000,000 numbers in float32. About how much memory does it use, and how much in float16?
  1. 4 MB in float32, 2 MB in float16
  2. 32 MB in float32, 16 MB in float16
  3. 2 MB in float32, 4 MB in float16
  4. 1 MB in both
Show the answer

4 MB in float32, 2 MB in float16

float32 uses 32 bits, which is 4 bytes per number: 1,000,000 × 4 = 4,000,000 bytes, about 4 MB. float16 uses 2 bytes per number, so about 2 MB. The 32 counts bits, not bytes.

Practice what you just read

  • 5 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

What's the default dtype of torch.tensor([0.5, 1.5])?

  1. torch.float32
  2. torch.float64
  3. torch.int64
  4. torch.float16

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson