import dnnlpy.nn as dnn
import dnnlpy.models.gpt as gpt
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch import Tensor
print('PyTorch version:', torch.__version__)PyTorch version: 2.13.0+cpu
jshn9515
2026-06-18
2026-06-18
In the previous sections, we completed three things:
vocab_size comes from.Looking back at the structure of MiniGPT, we can see an important symmetry at its two ends:
Both the input and output are related to the same vocabulary.
At the input, the model needs to convert token ids into vectors. At the output, it needs to map hidden states back to vocabulary-sized logits. In other words, a language model needs to work with the vocabulary at both ends.
This section takes a closer look at these two ends:
PyTorch version: 2.13.0+cpu
Suppose the tokenizer’s vocabulary size is \(V\) and the model’s hidden size is \(D\).
The token embedding at the input is a lookup matrix:
\[ E \in \mathbb{R}^{V \times D} \]
Its role is:
\[ \text{token id} \rightarrow \text{token vector} \]
The LM head at the output is a linear layer:
\[ W_{\text{out}} \in \mathbb{R}^{V \times D} \]
Its role is:
\[ \text{hidden state} \rightarrow \text{logits over vocabulary} \]
Therefore, the shapes at the two ends are very similar:
token embedding weight: (V, D)
lm head weight: (V, D)
The difference is the direction in which they are used.
At the input, we use a token id to select a row of E:
\[ x_t = E[\text{token\_id}_t] \]
At the output, we take the inner product of the hidden state with every row of W_out to obtain the logit for each token:
\[ \text{logits}_t = h_t W_{\text{out}}^\top \]
In other words, the input side maps an id to a vector, while the output side scores ids from a vector.
Let us first look at the shapes with a small example.
vocab_size = 10
embed_dim = 4
batch_size = 2
block_size = 5
input_ids = torch.tensor(
[
[1, 3, 5, 7, 9],
[2, 4, 6, 8, 0],
]
)
embedding = nn.Embedding(vocab_size, embed_dim)
lm_head = dnn.Linear(embed_dim, vocab_size, bias=False)
x_emb = embedding(input_ids)
logits = lm_head(x_emb)
print('Embedding weight shape:', embedding.weight.shape)
print('LM head weight shape:', lm_head.weight.shape)
print('input_ids.shape:', input_ids.shape)
print('x_emb.shape:', x_emb.shape)
print('logits.shape:', logits.shape)Embedding weight shape: torch.Size([10, 4])
LM head weight shape: torch.Size([10, 4])
input_ids.shape: torch.Size([2, 5])
x_emb.shape: torch.Size([2, 5, 4])
logits.shape: torch.Size([2, 5, 10])
The shape changes are:
\[ (B, T) \rightarrow (B, T, D) \rightarrow (B, T, V) \]
This is the basic path from token ids to vocabulary predictions in a language model.
An easy point to misunderstand is that although token ids are integers, they do not have numerical meaning themselves.
For example, a tokenizer might contain:
"deep" -> 12
"learning" -> 503
"GPT" -> 87
This does not mean that learning is greater than deep, nor does the distance between token 503 and token 12 have semantic meaning. Therefore, the model does not directly take these integers as continuous numerical inputs.
What nn.Embedding does is more like a table lookup:
token id 12 -> embedding.weight[12]
token id 503 -> embedding.weight[503]
token id 87 -> embedding.weight[87]
We can also think of a token id as a one-hot vector. Suppose the vocabulary size is \(V\) and the one-hot representation of the \(i\)-th token is \(e_i\). Then embedding can be written as:
\[ x_i = e_i^\top E \]
In the actual implementation, however, we do not construct one-hot vectors because that would be wasteful. PyTorch directly selects the corresponding row from the id.
vocab_size = 5
embed_dim = 3
embedding = nn.Embedding(vocab_size, embed_dim)
token_id = torch.tensor([2])
lookup_result = embedding(token_id)
manual_result = embedding.weight[2]
print('Embedding table:', embedding.weight, sep='\n')
print('Lookup result:', lookup_result)
print('Manual result:', manual_result)
flag = torch.allclose(lookup_result.squeeze(0), manual_result)
print('Is lookup result equal to manual result?', flag)Embedding table:
Parameter containing:
tensor([[ 0.1262, -1.6522, 1.0361],
[-1.6134, -0.9388, 0.1729],
[-1.1749, -0.3137, 0.0331],
[ 0.2916, -0.6595, -2.8494],
[-1.5151, 0.6857, -1.1909]], requires_grad=True)
Lookup result: tensor([[-1.1749, -0.3137, 0.0331]], grad_fn=<EmbeddingBackward0>)
Manual result: tensor([-1.1749, -0.3137, 0.0331], grad_fn=<SelectBackward0>)
Is lookup result equal to manual result? True
Thus, the essence of embedding is:
Learn a vector representation for every token in the vocabulary.
At the beginning of training, these vectors are usually initialized randomly. During training, backpropagation continually adjusts them so that they are useful for next-token prediction.
In Section 18.2, we added token embedding and positional embedding together:
\[ X = E_{\text{token}}[\text{input\_ids}] + E_{\text{pos}}[\text{positions}] \]
The two embeddings are similar in that both perform lookups, but their meanings are different.
The size of the token embedding table is determined by the vocabulary:
\[ E_{\text{token}} \in \mathbb{R}^{V \times D} \]
The size of the positional embedding table is determined by the maximum context length:
\[ E_{\text{pos}} \in \mathbb{R}^{T_{\max} \times D} \]
They are added together because the model needs to know two pieces of information at the same time:
vocab_size = 100
block_size = 8
embed_dim = 16
input_ids = torch.tensor([[10, 25, 31, 7]])
B, T = input_ids.size()
tok_embed = nn.Embedding(vocab_size, embed_dim) # word/token embedding
pos_embed = nn.Embedding(block_size, embed_dim) # positional embedding
positions = torch.arange(T)
x_tok = tok_embed(input_ids)
x_pos = pos_embed(positions)
x = x_tok + x_pos
print('input_ids.shape:', input_ids.shape)
print('positions.shape:', positions.shape)
print('x_tok.shape:', x_tok.shape)
print('x_pos.shape:', x_pos.shape)
print('x.shape:', x.shape)input_ids.shape: torch.Size([1, 4])
positions.shape: torch.Size([4])
x_tok.shape: torch.Size([1, 4, 16])
x_pos.shape: torch.Size([4, 16])
x.shape: torch.Size([1, 4, 16])
Note that positional embedding does not participate in the vocabulary prediction produced by the LM head. The LM head only cares which token in the vocabulary is next, not what the next position number is. Therefore, weight tying usually occurs only between token embedding and the LM head, not between positional embedding and the LM head.
After passing through the GPT blocks, each position has a hidden state:
\[ h_t \in \mathbb{R}^{D} \]
The LM head must assign a score to every token in the vocabulary:
\[ \text{logits}_t \in \mathbb{R}^{V} \]
Suppose the LM head’s weight is:
\[ W_{\text{out}} \in \mathbb{R}^{V \times D} \]
Then the logit for token \(j\) can be written as:
\[ \text{logit}_{t,j} = h_t \cdot W_{\text{out},j} \]
Here, \(W_{\text{out},j}\) is row \(j\) of the LM head’s weight matrix. It can also be understood as the vector learned by the output side for token \(j\).
Thus, the LM head can be understood as:
Take the hidden state at the current position and score its similarity with the output vector of every token in the vocabulary.
Strictly speaking, this is only an inner-product score from a linear layer and is not necessarily a normalized similarity. Only after softmax does it become a probability distribution:
\[ p(x_{t+1}=j \mid x_{\le t}) = \frac{\exp(\text{logit}_{t,j})} {\sum_{k=1}^{V} \exp(\text{logit}_{t,k})} \]
During training, we usually pass the logits directly into cross entropy.
B, T, D = 2, 4, 16
V = 100
h = torch.randn(B, T, D)
lm_head = nn.Linear(D, V, bias=False)
logits = lm_head(h)
labels = torch.randint(0, V, (B, T))
loss = F.cross_entropy(
logits.reshape(B * T, V),
labels.reshape(B * T),
)
print('h.shape:', h.shape)
print('logits.shape:', logits.shape)
print('labels.shape:', labels.shape)
print('Loss:', loss.item())h.shape: torch.Size([2, 4, 16])
logits.shape: torch.Size([2, 4, 100])
labels.shape: torch.Size([2, 4])
Loss: 5.060210227966309
Here, labels[b, t] is the true next-token id that sample b at position t should predict.
Now consider a key question.
The token embedding at the input has a table:
\[ E \in \mathbb{R}^{V \times D} \]
The LM head at the output also has a weight with the same shape:
\[ W_{\text{out}} \in \mathbb{R}^{V \times D} \]
Since both are learning vectors for tokens in the same vocabulary, can we simply share the same set of parameters? This is weight tying.
The simplest form is:
Now tok_embed.weight and lm_head.weight are no longer two independent sets of parameters; they are the same parameter object.
Intuitively, weight tying means that the model satisfies:
The representation of a token when it is used as input and the representation of the same token when it is an output candidate use the same vector space.
The output logit can then be written as:
\[ \text{logit}_{t,j} = h_t \cdot E_j \]
Here, \(E_j\) is both the input embedding of token \(j\) and the token vector used for scoring at the output.
Next, let us write a minimal example.
class TiedEmbeddingLM(nn.Module):
"""A tiny model that only demonstrates embedding, LM head, and weight tying."""
def __init__(self, vocab_size: int, embed_dim: int = 128):
super().__init__()
self.token_embed = dnn.Embedding(vocab_size, embed_dim)
self.lm_head = dnn.Linear(embed_dim, vocab_size, bias=False)
# Weight tying: both modules share the same Parameter object.
self.lm_head.weight = self.token_embed.weight
assert self.lm_head.weight is self.token_embed.weight
def forward(self, input_ids: Tensor) -> Tensor:
x = self.token_embed(input_ids)
logits = self.lm_head(x)
return logitsCheck whether they are the same parameter object:
model = TiedEmbeddingLM(vocab_size=20, embed_dim=8)
print('Embedding weight shape:', model.token_embed.weight.shape)
print('LM head weight shape: ', model.lm_head.weight.shape)
flag1 = model.token_embed.weight is model.lm_head.weight
flag2 = model.token_embed.weight.data_ptr() == model.lm_head.weight.data_ptr()
print('Is the same object?', flag1)
print('Do they share the same data pointer?', flag2)Embedding weight shape: torch.Size([20, 8])
LM head weight shape: torch.Size([20, 8])
Is the same object? True
Do they share the same data pointer? True
As we can see, the weight attributes of the two modules point to the same parameter.
Without weight tying, embedding and the LM head each have a separate set of \(V \times D\) parameters:
\[ \text{params}_{\text{untied}} = VD + VD = 2VD \]
With weight tying, the two components share one set of parameters:
\[ \text{params}_{\text{tied}} = VD \]
In other words, this alone saves \(VD\) parameters.
When the vocabulary is large, this saving is significant. For example:
Untied params: 76,800,000
Tied params: 38,400,000
Saved params: 146.4844 MB
This only accounts for the parameters at the embedding and LM head. For a larger model with a larger vocabulary, the savings are even more noticeable.
However, weight tying is not only about saving parameters. More importantly, it imposes a constraint on the input and output token representations: they must share the same vector space. This is usually reasonable because the input and output sides of a language model face the same set of tokens.
After weight tying, the same parameter is used twice during the forward pass:
Therefore, during backpropagation it also receives two sources of gradient contributions. We can see this with a small example.
vocab_size = 10
model = TiedEmbeddingLM(vocab_size=vocab_size, embed_dim=4)
input_ids = torch.tensor([[1, 2, 3]])
labels = torch.tensor([[2, 3, 4]])
logits = model(input_ids)
loss = F.cross_entropy(
logits.reshape(-1, vocab_size),
labels.reshape(-1),
)
loss.backward()
print('Loss:', loss.item())
print('Embedding grad shape:', model.token_embed.weight.grad.shape)
flag = model.lm_head.weight.grad is model.token_embed.weight.grad
print('Is the gradient the same object?', flag)Loss: 7.103814601898193
Embedding grad shape: torch.Size([10, 4])
Is the gradient the same object? True
Because they are the same parameter, the gradients also accumulate into the same .grad attribute.
More specifically, the shared weight has two paths of use in the computation graph: one from the input embedding and one from the output LM head. During backpropagation, the two paths produce gradient contributions separately, and these are automatically added to the same .grad. The optimizer then uses the total gradient to update the shared parameter only once:
\[ \left. \frac{\partial L}{\partial W} \right|{\text{Embedding}} + \left. \frac{\partial L}{\partial W} \right|{\text{LM Head}} \]
Thus, these are not two parameters updated separately, nor is the gradient simply multiplied by 2. The two paths jointly determine the update to the same parameter. Weight tying does not copy one set of weights to another; it makes the two locations genuinely share the same learnable parameter.
Returning to MiniGPT, weight tying usually requires changing only the definition and initialization of the LM head.
Without shared weights, we might write:
To share weights, a common implementation is:
Next, write a MiniGPT version with weight tying.
class MiniGPTWithWeightTying(nn.Module):
"""A MiniGPT model with weight tying between token embedding and LM head."""
def __init__(
self,
vocab_size: int,
block_size: int, # or context window
embed_dim: int = 128,
num_layers: int = 4,
num_heads: int = 4,
hidden_dim: int = 512,
dropout: float = 0.0,
):
super().__init__()
self.minigpt = gpt.MiniGPT(
vocab_size,
block_size,
embed_dim=embed_dim,
num_layers=num_layers,
num_heads=num_heads,
hidden_dim=hidden_dim,
dropout=dropout,
weight_tying=True, # Enable weight tying
)
def forward(self, input_ids: Tensor) -> Tensor:
return self.minigpt(input_ids)
def loss(self, input_ids: Tensor, targets: Tensor | None = None) -> Tensor:
return self.minigpt.loss(input_ids, targets)Test it:
model = MiniGPTWithWeightTying(
vocab_size=100,
block_size=8,
embed_dim=32,
)
token_ids = torch.tensor([[10, 25, 31, 7, 42]])
input_ids = token_ids[:, :-1]
labels = token_ids[:, 1:]
logits = model(input_ids)
loss = model.loss(token_ids, labels)
print('input_ids.shape:', input_ids.shape)
print('labels.shape:', labels.shape)
print('logits.shape:', logits.shape)
print('Loss:', loss.item())
flag = model.minigpt.token_embed.weight is model.minigpt.lm_head.weight
print('Is embedding weight the same object as LM head weight?', flag)input_ids.shape: torch.Size([1, 4])
labels.shape: torch.Size([1, 4])
logits.shape: torch.Size([1, 4, 100])
Loss: 4.563304901123047
Is embedding weight the same object as LM head weight? True
As we can see, the input and output sides do indeed share the same weights.
First, vocab_size affects both the embedding and the LM head.
Suppose the tokenizer’s vocabulary size is \(V\). Then:
token_embed: (V, D)
lm_head: (D -> V)
logits: (B, T, V)
Therefore, if we change the tokenizer and the vocabulary size changes, the model’s token embedding and LM head must change with it. This is also why we cannot arbitrarily pair one tokenizer with an incompatible model. The meaning of token ids and the model parameters correspond one-to-one.
Second, embed_dim must match the hidden size. The dimension of the token embedding must equal the hidden size accepted by the LM head.
That is:
token_embed.weight: (V, D)
lm_head.weight: (V, D)
If the GPT blocks output (B, T, D), the LM head can directly use the same (V, D) weight for the output projection.
Finally, the LM head outputs logits, not token ids.
The output of the LM head is:
\[ \text{logits} \in \mathbb{R}^{B \times T \times V} \]
These are not the final tokens yet.
During training, the logits enter cross entropy. During generation, we use the logits from the last position for argmax or sampling.
Prefix: tensor([[10, 25, 31]])
Last_logits shape: torch.Size([1, 100])
Next token: tensor([31])
There is one more point to note: weight tying occurs only between token embedding and the LM head; it does not include positional embedding. Positional embedding learns vectors for position indices and has shape (block_size, D), while the LM head outputs vocabulary logits with shape (B, T, V). Therefore, there is no meaningful weight sharing between positional embedding and the LM head.
This section took a closer look at the two parts of MiniGPT that are easiest to overlook: embedding and the LM head.
At the input:
\[ \text{input\_ids} \in \mathbb{N}^{B \times T} \rightarrow X \in \mathbb{R}^{B \times T \times D} \]
At the output:
\[ H \in \mathbb{R}^{B \times T \times D} \rightarrow \text{logits} \in \mathbb{R}^{B \times T \times V} \]
In this process:
At this point, the structure of MiniGPT is essentially clear.
Next, we will turn to training: how to batch a long sequence of tokens, how to set the context length, how to compute the loss, and which parameters a single training loop actually updates.