PyTorch tensors
NumPy-like arrays that run on GPUs.
Create a free account Try part of it here
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
- A new box for numbers you already know
- The same operations, with dim instead of axis
- Reshaping
- The shape inside a language model
- Worked example: a tiny batch of messages
- 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)
Here's what each line did:
torch.tensor(...)turned a nested list into a tensor. Its shape istorch.Size([2, 3]). Atorch.Sizebehaves like a tuple, soa.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'srange.torch.randn(2, 2)drew random numbers from the bell curve, centered on 0. Setting the seed first withmanual_seedmakes the "random" numbers the same on every run.- The last line shows the default dtypes:
int64for whole numbers andfloat32for 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.
import torch print(torch.tensor([7, 8, 9]).dtype)
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 + 1works on every number on its own. 1 becomes 3, 2 becomes 5, and so on.x @ x.Tis matrix multiplication.x.Tisxflipped, 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:
dimnames the dimension you squash.dim=0squashes the rows, so you get one answer per column.dim=1squashes 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.
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-1means "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:
viewandflattenregroup the numbers.transposeandpermutechange the order of the dimensions.unsqueezeadds a size-1 dimension. None of them change how many numbers there are.
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).
Read shapes from the outside in: batch, position, features. Most tensor bugs are a dimension in the wrong slot.
import torch x = torch.arange(12) print(x.view(3, -1).shape)
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: 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])
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.
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
Try a quiz question
What's the default dtype of torch.tensor([0.5, 1.5])?