Adapted from A deep understanding of AI language model mechanisms — https://udemy.com/course/dullms_x
1. Setup and Initialization
We'll start by importing our standard data science stack and loading the GPT-2 model and tokenizer to extract the embedding weights.
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec
# highres plots
import matplotlib_inline.backend_inline
matplotlib_inline.backend_inline.set_matplotlib_formats('svg')Import GPT-2 model and extract its embeddings matrix
from transformers import GPT2Model,GPT2Tokenizer
# pretrained GPT-2 model and tokenizer
gpt2 = GPT2Model.from_pretrained('gpt2')
tokenizer = GPT2Tokenizer.from_pretrained('gpt2')
embeddings = gpt2.wte.weight.detach().numpy()/Users/drippy/.pyenv/versions/3.12.6/lib/python3.12/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
from .autonotebook import tqdm as notebook_tqdm2. Converting Numbers to Tokens
How does a tokenizer handle numbers? Let's iterate through a range of integers to see which token IDs are assigned to them and how they are decomposed.
# create some numbers
numbers = np.arange(11)
numbers = np.concatenate( (numbers,10*numbers[2:],100*numbers[2:]), axis=0)
# initialize token vector
numTokenLabels = np.zeros(len(numbers))
# get and report the tokens
for i,n in enumerate(numbers):
# get the first token for this number
numTokenLabels[i] = tokenizer.encode(str(n))[0]
# try /2
print(f'The number {n:5} is token(s) {tokenizer.encode(str(n))}')The number 0 is token(s) [15]
The number 1 is token(s) [16]
The number 2 is token(s) [17]
The number 3 is token(s) [18]
The number 4 is token(s) [19]24 more lines
The number 5 is token(s) [20]
The number 6 is token(s) [21]
The number 7 is token(s) [22]
The number 8 is token(s) [23]
The number 9 is token(s) [24]
The number 10 is token(s) [940]
The number 20 is token(s) [1238]
The number 30 is token(s) [1270]
The number 40 is token(s) [1821]
The number 50 is token(s) [1120]
The number 60 is token(s) [1899]
The number 70 is token(s) [2154]
The number 80 is token(s) [1795]
The number 90 is token(s) [3829]
The number 100 is token(s) [3064]
The number 200 is token(s) [2167]
The number 300 is token(s) [6200]
The number 400 is token(s) [7029]
The number 500 is token(s) [4059]
The number 600 is token(s) [8054]
The number 700 is token(s) [9879]
The number 800 is token(s) [7410]
The number 900 is token(s) [12865]
The number 1000 is token(s) [12825]Token Value Visualization
By plotting the number against its token ID, we can see if there is any linear relationship or pattern in the way the tokenizer assigns IDs to numeric strings.
plt.figure(figsize=(10,4))
plt.plot(numbers,numTokenLabels,color=[.5,.5,.5],linewidth=.5)
plt.scatter(numbers,numTokenLabels,c=np.arange(len(numbers)),s=100,marker='s',cmap='plasma_r',zorder=10)
plt.gca().set(xlabel='Number (as string)',ylabel='Token value',xticks=numbers,xlim=[numbers[0]-15,numbers[-1]+15])
# plt.gca().set(xscale='log')
plt.show()3. Token Length Analysis
Integers and floating-point numbers are decomposed differently. We'll analyze the number of tokens required to represent various numeric types.
# tokenize integers and floating-point numbers
# initialize
numnums = 99_999
int_toklens = np.zeros(numnums,dtype=int)
float_toklens = np.zeros(numnums,dtype=int)
# random numbers
ra = 5*np.random.randn(numnums)
for i in range(numnums):
# integers
int_toklens[i] = len(tokenizer.encode(str(i)))
# and the random numbers
float_toklens[i] = len(tokenizer.encode(str(ra[i])))_,axs = plt.subplots(1,2,figsize=(12,4))
axs[0].plot(int_toklens+np.random.randn(numnums)/30,'s',markerfacecolor=[.7,.7,.9],alpha=.4)
axs[1].plot(ra,float_toklens+np.random.randn(numnums)/50,'o',markerfacecolor=[.7,.9,.7],alpha=.4)
axs[0].set(xlabel='Number',ylabel='Token length',yticks=range(int_toklens.max()+2),title='Token lengths of integers')
axs[1].set(xlabel='Number',ylabel='Token length',title='Token lengths of floating-point numbers')
plt.tight_layout()
plt.show()
4. Arithmetic on Token IDs
Does multiplying two token IDs result in a meaningful new token? This exercise demonstrates that token IDs are arbitrary indices and do not follow mathematical logic.
# the equation and its tokens
eq = '5 x 3 ='
tokens = tokenizer.encode(eq)
# try the math
print(f'{eq} -> {tokens}')
print(f'Product of tokens = {np.prod(tokens)}')
# Updated line in cell 7
print(f' which is "{tokenizer.decode(int(np.prod(tokens))) if np.prod(tokens) < tokenizer.vocab_size else "[OUT OF VOCAB]"}"')5 x 3 = -> [20, 2124, 513, 796]
Product of tokens = 17346623040TypeError: 'NoneType' object is not iterable
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[7], line 8
6 print(f'{eq} -> {tokens}')21 more lines
7 print(f'Product of tokens = {np.prod(tokens)}')
----> 8 print(f' which is "{tokenizer.decode(np.prod(tokens))}"')
File ~/.pyenv/versions/3.12.6/lib/python3.12/site-packages/transformers/tokenization_utils_base.py:3870, in PreTrainedTokenizerBase.decode(self, token_ids, skip_special_tokens, clean_up_tokenization_spaces, **kwargs)
3867 # Convert inputs to python lists
3868 token_ids = to_py_obj(token_ids)
-> 3870 return self._decode(
3871 token_ids=token_ids,
3872 skip_special_tokens=skip_special_tokens,
3873 clean_up_tokenization_spaces=clean_up_tokenization_spaces,
3874 **kwargs,
3875 )
File ~/.pyenv/versions/3.12.6/lib/python3.12/site-packages/transformers/tokenization_utils.py:1104, in PreTrainedTokenizer._decode(self, token_ids, skip_special_tokens, clean_up_tokenization_spaces, spaces_between_special_tokens, **kwargs)
1102 current_sub_text = []
1103 # TODO @ArthurZ in version 5, special tokens should be handled in convert_tokens_to_string, while _convert_tokens_to_string
-> 1104 for token in filtered_tokens:
1105 if skip_special_tokens and token in self.all_special_tokens:
1106 continue
TypeError: 'NoneType' object is not iterable# maybe just the numbers?
t5 = tokenizer.encode('5')
t3 = tokenizer.encode('3')
# let's see...
print(f'"5" and "3" have tokens {t5} and {t3}.')
print(f'Their product is {t5[0]*t3[0]}, which is "{tokenizer.decode(int(t5[0]*t3[0])) if t5[0]*t3[0] < tokenizer.vocab_size else "[OUT OF VOCAB]"}"')"5" and "3" have tokens [20] and [18].
Their product is 360, which is " D"5. Vector Math with Embeddings
Unlike token IDs, the high-dimensional embedding vectors are designed to capture semantic meaning. We can perform vector addition and multiplication to see how they interact.
# isolate the embedding vectors
e5 = embeddings[t5,:].squeeze()
e3 = embeddings[t3,:].squeeze()
# math
theirSum = e3+e5
theirProd = e3*e5
# plot the vectors
plt.figure(figsize=(12,4))
plt.plot(e3,label='3')
plt.plot(e5,label='5')
plt.plot(theirSum,label='3+5')
plt.plot(theirProd,label='3x5')
plt.gca().set(xlabel='Embeddings dimension',ylabel='Value',xlim=[0,len(e3)])
plt.legend()
plt.show()Unembedding Result
To decode a vector back into a token, we use unembedding. This process finds the token in the vocabulary that is most similar to our calculated vector :
- : The target vector (e.g., $e3 + e5$).
- : Transpose of the embedding matrix. Multiplying by calculates the dot product (similarity) with every token in the vocabulary.
- : Returns the index of the token with the highest similarity score.
GPT models use Weight Tying, meaning the same matrix is used for both input embedding and output unembedding.
# Project back to vocab space using matrix transpose (dot product similarity)
sumUnembedding = theirSum @ embeddings.T
prodUnembedding = theirProd @ embeddings.T
# Check dimensions: (embedding_dim) @ (embedding_dim, vocab_size) -> (vocab_size)
print(f'Summed vector X embeddings matrix = unembeddings vector')
print(f' {theirSum.shape} X {embeddings.shape} = {sumUnembedding.shape}\n')
# Use argmax to find the most similar token index and decode to string
print(f'Max embedding of 5+3 = "{tokenizer.decode(np.argmax(sumUnembedding))}"')
print(f'Max embedding of 5x3 = "{tokenizer.decode(np.argmax(prodUnembedding))}"')Summed vector X embeddings matrix = unembeddings vector
(768,) X (50257, 768) = (50257,)
Max embedding of 5+3 = "5"
Max embedding of 5x3 = " Weinstein"Why does 5 + 3 unembed as "5"? Even though we added the vector for "3", the resulting vector remains most similar to the original "5" token. Because the cosine similarity is still highest with "5", the argmax operation selects it as the output. This highlights that simple vector addition doesn't perform mathematical arithmetic; it just shifts the position in high-dimensional space.
# curious to see how the embeddings for '3' and '5' compare
plt.plot(e5,e3,'.')
plt.show()Key Take-home Points
- Arbitrary Mapping: Token IDs are purely indices and have no mathematical relationship to the value they represent (e.g., token ID 20 times 18 is not token ID 360).
- Numerical Tokenization: Tokenizers often break large numbers or floats into multiple sub-tokens, which can make mathematical reasoning difficult for LLMs.
- Vector Logic: Performing math on embedding vectors (like addition) results in a new vector that can be "unembedded" to see which token it is most similar to.
- Semantic Space: While vector math is the basis for attention mechanisms, simple arithmetic on embeddings rarely results in precise mathematical outcomes.