You take a few rows from a batch, change a value, and discover that the original changed too. Often nothing mysterious happened: two tensors refer to the same memory.
Keep three questions separate: where the values live, how their shape maps onto memory, and whether autograd records an operation. This chapter covers the first two; autograd covers the third.
Name the axes first
Suppose a batch contains 2 sequences, each with 3 tokens represented by 4 numbers. Its shape is [2,3,4], which we interpret as [batch, time, feature]. Those names are our convention, not information PyTorch automatically understands.
| Inspection | Result here | Question answered |
|---|---|---|
values.shape / values.size() |
(2,3,4) |
How long is each axis? |
values.ndim |
3 | How many axes? |
values.numel() |
24 | How many elements? |
values.dtype |
For example, float32 |
How is each value represented? |
values.device |
For example, cpu |
Where is it stored and computed? |
type(values) |
torch.Tensor |
What is the Python object type? |
[4] has one axis: it is neither a [1,4] row matrix nor a [4,1] column matrix. A scalar has shape [] and one element; an empty vector with shape [0] has zero elements.
import torch
values = torch.arange(24, dtype=torch.float32).reshape(2, 3, 4)
assert values.shape == (2, 3, 4)
assert values.ndim == 3 and values.numel() == 24
assert torch.tensor(5.0).shape == ()
assert torch.empty(0).numel() == 0
assert values[0].shape == (3, 4)
assert values[0:1].shape == (1, 3, 4)
An integer index removes an axis; a slice preserves it. The last two expressions select the same sequence, but only one preserves a batch axis. Choose deliberately.
Be explicit when creating tensors
torch.tensor([1,2]) normally infers int64; floating-point lists use the current default floating dtype. Specify the training contract: class IDs commonly use long, continuous features use floating point, and masks use bool. Floating-point model parameters do not imply floating-point class indices.
| Need | Common entry points | Important distinction |
|---|---|---|
| Fixed values | tensor, zeros, ones, full |
The fill value also affects full dtype inference |
| Regular sequence | arange, linspace |
Usually exclude the endpoint versus include both endpoints with a specified count |
| Random values | rand, randn, randint |
Uniform [0,1), standard normal, and half-open integer range |
| Specified normal distribution | normal |
Supply standard deviation, not variance |
| Match an existing tensor | zeros_like, randn_like |
Inherit dtype/device by default; default randn_like on an integer tensor fails |
| Allocate storage only | empty |
Uninitialized values, not zeros or reliably tiny numbers |
Use empty for a buffer whose entire contents will be overwritten. Specific determinism settings can fill special values, but that does not make it a useful numerical initialization. The official documentation describes the exception.
.to(dtype=..., device=...) returns the conversion result and may return the original object when no conversion is needed. Neither it nor .float() guarantees a copy. Type promotion is operator-specific: addition can promote types, whereas common matrix products require matching types. PyTorch does not scan every element to discover its type before every operation.
A storage picture
This picture describes storage, not gradient connections:
storage: [0, 1, 2, 3, 4, 5]
└───────┬──────┘
base [2,3]: [[0,1,2], [3,4,5]] stride=(3,1)
transpose: [[0,3], [1,4], [2,5]] stride=(1,3)
clone: [0, 1, 2, 3, 4, 5] independent storage
For an ordinary strided tensor, the storage offset for an element is storage_offset + Σ index[axis] × stride[axis]. Transposition changes how values are addressed without necessarily moving them. Different objects and shapes can share storage.
base = torch.arange(6).reshape(2, 3)
window = base[:, 1:]
snapshot = window.clone()
window[0, 0] = 50
assert base[0, 1].item() == 50
assert snapshot[0, 0].item() == 1
assert window is not base
alias = base names the same Python object. window is another object sharing storage. clone allocates independent storage. A new object is not necessarily new data. See Tensor Views.
Reshape is not transpose
reshape regroups values in their current logical order; transpose and permute change axis correspondence. To exchange time and features in [B,T,D] → [B,D,T], use transpose(1,2) rather than merely requesting that shape.
view requires compatible sizes and strides. Contiguous tensors readily support many views, but non-contiguous does not always mean impossible. reshape shares when possible and copies otherwise; correct code should not depend on which it chooses.
grid = torch.arange(30).reshape(5, 6)
spaced = grid[:, ::2]
flat = spaced.view(-1)
assert not spaced.is_contiguous()
assert flat.stride() == (2,)
assert flat.tolist() == list(range(0, 30, 2))
swapped = grid.transpose(0, 1)
repacked = swapped.reshape(-1)
assert repacked[:5].tolist() == [0, 6, 12, 18, 24]
assert repacked.data_ptr() != swapped.data_ptr()
The first example can flatten while retaining a stride of two. The second needs to repack the transposed order; this particular reshape makes a copy. contiguous() can also return its input unchanged when the requested layout already holds. See the stride condition for view.
flatten(start_dim=1) commonly preserves the batch axis while combining later axes. squeeze(dim) removes a specified singleton axis; unrestricted squeeze() can accidentally delete a batch axis when batch size is one. unsqueeze(dim) inserts a singleton axis.
Selecting samples and assembling batches
Basic slicing usually returns a view. Integer-array/list and Boolean advanced indexing generally return copies. However, an assignment such as values[mask] = ... modifies the source. Copy behavior when reading does not make indexed assignment harmless.
values[-1] selects the last element; negative slice steps such as values[::-1] are unsupported, so use flip. matrix[::2,::2] strides along two axes; matrix[::2][::2] selects along the first axis twice.
| Operation needed | Choice | Axis behavior |
|---|---|---|
| Same selected columns for every row | index_select(dim, indices) |
One-dimensional indices; repeats allowed |
| Different selected columns per row | gather(dim, indices) |
Output shape follows the index; next chapter has an example |
| Keep elements meeting a condition | Boolean indexing / masked_select |
A full-shape mask commonly yields a flat selection |
| Choose between values | where(mask, left, right) |
Elementwise choice with broadcasting |
| Join along an existing axis | cat |
Other axes must match |
| Add an axis | stack |
Input shapes must match |
| Split by chunk size | split |
Integer argument is size, not count |
| Request a chunk count | chunk |
Can return fewer chunks; tensor_split uses the requested count |
first = torch.tensor([2, 4, 6])
second = torch.tensor([8, 10, 12])
assert torch.cat([first, second]).shape == (6,)
assert torch.stack([first, second]).shape == (2, 3)
assert [part.numel() for part in torch.arange(7).split(3)] == [3, 3, 1]
assert len(torch.arange(4).chunk(3)) == 2
assert len(torch.tensor_split(torch.arange(4), 3)) == 3
stack is not analogous to appending [3,4] to [1,2], which creates a ragged Python list. Split outputs form an immutable tuple, but their tensors can still modify shared storage. Check the chunk contract before unpacking a fixed number of outputs.
Crossing into NumPy or Python
torch.tensor(array) copies data; from_numpy and compatible as_tensor calls can share storage. Sharing avoids a copy but lets changes on either side affect training inputs.
Default .numpy() requires a CPU tensor not requiring gradients, with supported dtype/layout and other conditions. It normally shares storage. .numpy(force=True) handles detach, CPU transfer, and conjugate-related conversions, but does not guarantee an independent copy. Explicitly copy when you need a snapshot. See the numpy interface conditions.
.tolist() produces nested Python numbers; list(tensor) iterates over sub-tensors along the first axis. .item() needs exactly one element, not necessarily a zero-dimensional tensor. Its Python result carries no gradient connection. Frequent CUDA item() calls can also make the CPU wait, so use them for logging rather than constructing a differentiable loss.
When changing one tensor changes another, check shared storage. When shapes look right but meanings differ, check for a reshape used in place of an axis permutation. Next: valid code that computes the wrong quantity.