Part of Language AI Handbook
Explains how FastText extends Word2Vec with character n-grams to handle out-of-vocabulary words, typos.
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
FastText: Subword Embeddings and Character N-grams
Word2Vec opened the door to dense word representations learned from context. But it contains a subtle assumption that becomes a serious limitation at scale: every word is an atom. The embedding for "running" is entirely unrelated to the embedding for "run." They might live nearby in vector space after training, but that proximity is coincidence, not design. The model had to observe each word independently and hope its training signal was strong enough to push related words near each other.
This atomic view has concrete consequences. Any word absent from the training corpus receives no embedding at all. For English, this means rare words, proper nouns, typos, and new coinages all fail silently. The model simply has no answer for them, and downstream systems must cope with the gap through fallbacks that lose information. For morphologically rich languages like Finnish, Turkish, or German, the situation is far worse. Finnish nouns can take over a thousand different inflected forms. German constructs compound words without spaces, producing tokens like "Lebensversicherungsgesellschaft" (life insurance company) that almost never appear twice in any corpus. A Word2Vec model trained on German text cannot possibly memorize every compound, so most compounds are perpetually out-of-vocabulary.
FastText, introduced by Piotr Bojanowski, Edouard Grave, Armand Joulin, and Tomas Mikolov at Facebook AI Research in 2017, solves this by abandoning the atomic view. Instead of treating a word as a single unit, FastText treats it as a collection of overlapping character substrings called n-grams. "Running" becomes a bag containing the substrings "run," "unn," "nni," "nin," "ing," and several longer fragments. The word's final embedding is the sum of its n-gram embeddings. This one change, replacing a single lookup with a sum over substrings, is what makes FastText capable of constructing embeddings for words it has never seen.
The insight is borrowed from classical computational linguistics. For decades, researchers used character n-grams to represent documents for language identification and spam filtering, precisely because character substrings are robust to morphological variation and noise. FastText brings that robustness into the neural embedding framework that Word2Vec established.
This chapter develops FastText from first principles. We begin with the vocabulary problem in full detail, then work through the n-gram decomposition, the mathematical training objective, the hashing trick for memory efficiency, and finally examine where FastText still falls short and what came next. By the end, you will understand both why subword embeddings are so powerful and where the next generation of models had to go.
The Vocabulary Problem in Word2Vec
Before building FastText's solution, it is worth understanding the problem precisely. Word2Vec maintains a vocabulary of words observed during training. Each vocabulary word is represented by a unique embedding vector, and the model learns by predicting which words appear near each other in text. This works well when your test data closely resembles your training data and when your vocabulary is large enough to cover nearly all tokens you will encounter.
Consider what happens inside a Word2Vec model when you present a test sentence containing a word it has not seen.
import numpy as np
# Simulate a Word2Vec vocabulary trained on general English text
word2vec_vocab = {
"run": 0,
"running": 1,
"runner": 2,
"walk": 3,
"walking": 4,
"walker": 5,
"swim": 6,
"happy": 7,
"happily": 8,
}
# Each known word has a 50-dimensional embedding
np.random.seed(42)
word2vec_embeddings = {word: np.random.randn(50) for word in word2vec_vocab}
# Words that appear in real text but are not in vocabulary
problem_words = ["runs", "swam", "swimmer", "runing", "happyness"]Word2Vec vocabulary coverage: ------------------------------------------------------- Vocabulary size: 9 Known words: ['run', 'running', 'runner', 'walk', 'walking', 'walker', 'swim', 'happy', 'happily'] Words that cause failures: 'runs': NOT FOUND (inflected form of 'run') 'swam': NOT FOUND (past tense of 'swim') 'swimmer': NOT FOUND (noun derived from 'swim') 'runing': NOT FOUND (typo for 'running') 'happyness': NOT FOUND (misspelling of 'happiness')
Every word marked "NOT FOUND" will receive no embedding. Downstream models that accept embedding vectors must either skip these words or use a fallback zero vector, both of which lose information. In practice, the fallback zero vector introduces a subtle but persistent bias: every unknown word looks identical to every other unknown word in the embedding space, as if "swam," "runing," and "happyness" were all the same thing.
The problem intensifies across three scenarios that are far more common than toy examples suggest:
-
Morphologically rich languages: Finnish has fifteen grammatical cases; each noun can appear in dozens of forms depending on its grammatical role. Turkish stacks suffixes to express tense, person, plurality, and possession on a single word. A vocabulary-based model would need to memorize tens of thousands of forms per root, which is both impractical and wasteful, because each form is essentially the same concept expressed in a different grammatical context.
-
Domain-specific terminology: Medical literature is dense with terms like "bevacizumab," "electroencephalography," and "hypothyroidism." A model trained on Wikipedia will have none of these, leaving entire sentences without embeddings even though a knowledgeable reader would recognize that "electro" relates to electricity, "encephalo" refers to the brain, and "graphy" denotes measurement. The substructure carries meaning that the atomic word model ignores.
-
Noisy and informal text: Social media writing contains elongated spellings ("soooo"), abbreviations ("gr8"), creative portmanteaux ("hangry"), and deliberate misspellings. These carry clear meaning that a strict vocabulary lookup will miss entirely. A model deployed on social media content will encounter OOV tokens in a substantial fraction of sentences.
FastText addresses all three of these with the same mechanism: decomposing words into character substrings and learning embeddings for those substrings rather than for whole words. The key realization is that morphological information lives at the character level. Prefixes, roots, and suffixes are the meaningful units of word structure, and character n-grams approximate those units without requiring a dedicated morphological analyzer.
Character N-grams: Representing Words as Substrings
The core data structure in FastText is the character n-gram. For any word, FastText first wraps it in boundary markers, then extracts every contiguous substring of characters within a configured length range. The result is a set of overlapping fragments that collectively encode the word's character structure.
A character n-gram is a contiguous sequence of characters within a word. For the word "where," the 3-grams are "whe," "her," and "ere." FastText adds special boundary markers at the start and end of each word, transforming "where" into "<where>" before extraction. This ensures that "<wh" distinguishes a word-initial position from the interior substring "her," which could appear in many different words at different positions.
The function below extracts all n-grams within a length range. This is the literal preprocessing step applied to every word in the training corpus:
def get_ngrams(word, min_n=3, max_n=6):
"""
Extract character n-grams from a word with boundary markers.
The word is wrapped in < and > before extraction so that
prefix n-grams (like '<wh') and suffix n-grams (like 're>')
are distinguished from internal substrings.
"""
marked = f"<{word}>"
ngrams = []
for n in range(min_n, max_n + 1):
for i in range(len(marked) - n + 1):
ngrams.append(marked[i : i + n])
return ngrams
example_word = "where"
ngrams = get_ngrams(example_word, min_n=3, max_n=6)Character n-grams for 'where' (marked as '<where>'): ------------------------------------------------------- 3-grams (5): ['<wh', 'whe', 'her', 'ere', 're>'] 4-grams (4): ['<whe', 'wher', 'here', 'ere>'] 5-grams (3): ['<wher', 'where', 'here>'] 6-grams (2): ['<where', 'where>'] Total n-grams: 14
The word "where" produces 18 n-grams spanning lengths 3 to 6. Notice what these substrings encode. The 3-gram "<wh" can only appear at the very start of a word: the "<" marker ensures this. Similarly, "re>" can only appear at a word ending. The internal 3-gram "her" has no positional constraint, so it can match "hero," "mother," "together," and any other word containing that substring in the middle. This positional disambiguation is one of the key functions of the boundary markers.
The n-gram length range is a deliberate design choice. A minimum of 3 avoids extremely short substrings like "re" or "he" that appear in so many words they carry little discriminative information. A maximum of 6 is long enough to capture most productive English morphemes: the suffix "-ation" is 6 characters, "-ment" is 4, the prefix "pre-" is 3. For languages with longer morphemes, the maximum can be extended.
Why Boundary Markers Matter
Boundary markers do more than look like a clever trick. They fundamentally change which words share n-grams and by how much. Without markers, "where," "hero," and "other" would all share the trigram "her," conflating three very different words in ways that do not reflect their linguistic relationship. With markers, each word develops a distinct positional fingerprint.
# Compare how boundary markers affect n-gram sets
words_with_her = ["where", "hero", "other"]
trigram_sets = {}
for word in words_with_her:
marked = f"<{word}>"
trigrams = {marked[i : i + 3] for i in range(len(marked) - 2)}
trigram_sets[word] = trigramsEffect of boundary markers on trigram overlap:
-------------------------------------------------------
'where' -> '<where>':
Trigrams: ['<wh', 'ere', 'her', 're>', 'whe']
'hero' -> '<hero>':
Trigrams: ['<he', 'ero', 'her', 'ro>']
'other' -> '<other>':
Trigrams: ['<ot', 'er>', 'her', 'oth', 'the']
The raw substring 'her' appears in all three words.
But with markers, each word's trigram set is distinct:
'hero' & 'where' share: ['her']
'hero' & 'other' share: ['her']
'other' & 'where' share: ['her']Without markers, "where," "hero," and "other" would all share the trigram "her," merging their embeddings in a way that does not reflect a linguistic relationship. The "her" inside "other" is a suffix fragment; the "her" in "where" is an internal sequence; the "her" at the start of "hero" is a prefix. These positions carry different information, and boundary markers let FastText model them separately.
The practical implication is significant. Consider morphologically related pairs like "run" and "runner." With boundary markers, "<run>" and "<runner>" share 3-grams "<ru," "run," "un>" (for the shorter word) and "<ru," "run," "unn," "nn," "nne," "ner," "er>" (for the longer word). Several markers align: the prefix fragments reflect that both words start the same way, and the "run" interior fragment captures the shared root. This shared structure is exactly what we want the model to exploit.
N-gram Count Scaling
The number of n-grams a word generates grows with its length. Short words have fewer substrings, which gives them less representational capacity. Long words have more, which helps with morphologically complex forms that pack meaning into their length.
# Measure how n-gram count scales with word length
test_words_by_length = {
3: ["run", "cat", "dog"],
4: ["walk", "fish", "jump"],
5: ["where", "think", "happy"],
6: ["runner", "moving", "faster"],
7: ["running", "walking", "jumping"],
8: ["swimming", "watching", "learning"],
10: ["understand", "completely", "experience"],
12: ["professional", "conversation", "appreciation"],
}
lengths = sorted(test_words_by_length.keys())
avg_counts = []
by_size = {n: [] for n in [3, 4, 5, 6]}
for length in lengths:
words = test_words_by_length[length]
counts = []
size_totals = {n: 0 for n in [3, 4, 5, 6]}
for word in words:
ngs = get_ngrams(word, min_n=3, max_n=6)
counts.append(len(ngs))
for ng in ngs:
if len(ng) in size_totals:
size_totals[len(ng)] += 1
avg_counts.append(np.mean(counts))
for n in [3, 4, 5, 6]:
by_size[n].append(size_totals[n] / len(words))

The roughly linear scaling is important. It means that the number of parameters used to represent a word grows proportionally with the word's length, which aligns well with the intuition that longer words carry more information per token. Short words like "run" (3 characters) get 9 n-grams; longer words like "appreciation" (12 characters) get around 45. This proportionality is not accidental: the architects of FastText chose the extraction range specifically to maintain this relationship between word complexity and representational capacity.
The distribution by n-gram size also matters. Shorter n-grams (3 and 4 characters) appear in many words, so their embeddings are updated frequently during training and tend to capture broad morphological patterns. Longer n-grams (5 and 6 characters) are more specific, appearing in fewer words, so they capture finer-grained structure. Together, the range from 3 to 6 provides both broad pattern recognition and fine-grained discrimination.
From N-grams to Word Vectors
With the decomposition defined, we need a mechanism to combine n-gram embeddings into a single word vector. FastText uses the simplest possible aggregation: summation. Every n-gram has its own learnable embedding vector in a shared table, and a word's representation is the sum of those vectors.
For any word , FastText defines:
where:
- : the final embedding vector for word , used during training and inference
- : an optional word-level embedding for words that appear in the vocabulary (omitted for out-of-vocabulary words)
- : the set of all character n-grams extracted from word using the boundary-marked extraction procedure
- : the learned embedding vector for n-gram , shared across every word that contains that substring
Why sum rather than average, concatenate, or use a learned aggregation? The sum is not arbitrary. The skip-gram training objective involves computing a dot product between the center word representation and context word representations. With summation, the dot product distributes over the sum:
This means that the score for a (center, context) pair decomposes into a sum of scores from individual n-grams. Each n-gram embedding receives a gradient that is directly proportional to how well that n-gram aligns with the context, which makes the gradient computation straightforward and the learning signal interpretable.
The critical design decision is that is shared. The n-gram "run" appears in "run," "running," "runner," "outrun," and dozens of other words. Every time any of these words appears in training, the gradient flows back to update the embedding for "run." This sharing makes subword embeddings an efficient form of information pooling: common morphological patterns accumulate training signal from every word that contains them, making the embedding for each n-gram far better estimated than it would be if each occurrence were treated independently.
For an out-of-vocabulary word, simply does not exist. The entire representation comes from . This is what enables zero-shot generalization to unseen words: as long as the word can be decomposed into familiar n-grams, a meaningful embedding can be constructed. No special case handling is needed; the architecture handles OOV words through the same summation mechanism it uses for known words.
The following class implements this representation, including the OOV case:
class FastTextEmbeddings:
"""
FastText-style word embeddings using character n-grams.
This is a conceptual implementation for understanding the architecture.
"""
def __init__(self, embedding_dim=50, min_n=3, max_n=6):
self.embedding_dim = embedding_dim
self.min_n = min_n
self.max_n = max_n
self.word_embeddings = {} # z_w: word-level embeddings for known vocab
self.ngram_embeddings = {} # z_g: n-gram embeddings shared across words
def _get_ngrams(self, word):
return get_ngrams(word, self.min_n, self.max_n)
def _init_ngram(self, ng):
if ng not in self.ngram_embeddings:
self.ngram_embeddings[ng] = (
np.random.randn(self.embedding_dim) * 0.01
)
def get_embedding(self, word):
"""
Compute embedding as sum of n-gram embeddings plus
optional word-level embedding for known vocab words.
"""
ngs = self._get_ngrams(word)
for ng in ngs:
self._init_ngram(ng)
# Start with word-level embedding if available
if word in self.word_embeddings:
vec = self.word_embeddings[word].copy()
else:
vec = np.zeros(self.embedding_dim)
# Add all n-gram embeddings
for ng in ngs:
vec += self.ngram_embeddings[ng]
return vec
np.random.seed(42)
ft = FastTextEmbeddings(embedding_dim=50)
# Seed some known words into the word-level embedding table
for w in ["run", "running", "runner", "swim", "swimming"]:
ft.word_embeddings[w] = np.random.randn(50) * 0.1
# Compute embeddings for known and out-of-vocabulary words
emb_running = ft.get_embedding("running") # in vocab
emb_runs = ft.get_embedding("runs") # OOV: inflected form
emb_runing = ft.get_embedding("runing") # OOV: typo
emb_swim = ft.get_embedding("swim") # in vocabEmbedding similarities (random initialization, no training): ------------------------------------------------------- 'run' vs 'runs' (inflection): 0.1958 'run' vs 'running' (inflection): 0.0469 'running' vs 'runing' (typo): 0.0091 'run' vs 'swim' (unrelated): 0.0584 Even with random weights, shared n-grams produce non-trivial similarity between morphologically related words.
Even before training, morphologically related words exhibit positive similarity because their n-gram sets overlap. "Runing" (a typo) retains most of "running"'s n-grams, so even with random weights the two are already more similar than "run" and "swim." Training amplifies this structural signal into meaningful semantic proximity. The pre-training similarity is a measure of structural relatedness; post-training similarity reflects semantic relatedness as well.
This is a useful diagnostic. If you initialize a FastText model with random weights and compute cosine similarities, morphological relatives should already show positive correlation. If they do not, something is wrong with the n-gram extraction. The structural bias is a feature, not a bug: it gives gradient descent a better starting point than purely random initialization, which is part of why FastText converges faster than word-level models on morphologically complex vocabularies.
The FastText Training Objective
FastText adopts Word2Vec's skip-gram framework: predict context words from center words. The modification is entirely in how the center word is represented. Instead of a single embedding lookup, the center word's representation is computed as a sum over its n-gram embeddings. This substitution is the only change to the original skip-gram architecture.
The compatibility score between center word and context word is:
where:
- : the real-valued compatibility score (higher values encourage co-occurrence)
- : the set of n-grams for the center word
- : the n-gram embedding for subword
- : the context embedding for the context word (a separate embedding table from the n-gram table)
This formula is equivalent to first summing all n-gram embeddings of and then computing a single dot product with . Writing it as a sum of dot products makes the gradient computation explicit: every n-gram in receives a gradient proportional to how well the n-gram aligns with the context embedding. The gradient for is the same regardless of which specific word caused the update, which is what lets knowledge flow across all words sharing that n-gram.
Negative Sampling Loss
Computing the full softmax over all vocabulary words is prohibitively slow when vocabularies contain hundreds of thousands of words. FastText uses negative sampling, the same approximation as Word2Vec. For each positive (center, context) pair, the model samples random negative words from the vocabulary and trains a binary classifier to distinguish the true pair from the fake ones.
The core idea behind negative sampling is this: instead of asking "what is the probability of this context word given the center word, compared to all other words?", we ask "can the model tell the difference between an observed co-occurrence pair and a randomly drawn pair?" This binary classification task is much cheaper and, empirically, produces embeddings of comparable quality.
The loss for a single training example is:
where:
- : the per-example objective, which we maximize (equivalently, we minimize )
- : the sigmoid function mapping scores to probabilities in
- : the positive term, rewarding high scores for observed pairs
- : the number of negative samples per positive example (typically 5 to 10)
- : the -th randomly sampled negative context word
- : the negative term, penalizing high scores for random pairs
Maximizing pushes the model to assign high scores to real co-occurrence pairs and low scores to random ones. Because the center word representation is a sum over n-gram embeddings, each gradient update trains one word embedding and all n-gram embeddings that appear in the center word simultaneously. A single training step on "running" updates the embeddings for "<ru," "run," "unn," "nni," "nin," "ing," "ng>," and every other n-gram in the word. This parameter sharing is the mechanism that makes FastText sample-efficient: every observation of any word containing a morpheme contributes to refining the morpheme's embedding.
The negative sampling distribution matters. FastText follows Word2Vec in using a smoothed unigram distribution raised to the power :
where is the word's frequency in the training corpus. Raising to raises the probability of sampling rare words relative to what a pure frequency-proportional distribution would give. This prevents the most common words from dominating the negative samples, which would make training ignore rare words entirely.
Implementing FastText Training
The following implementation brings the architecture to life. We maintain a matrix of n-gram embeddings and a separate matrix of context (output) embeddings for vocabulary words. Forward pass: sum n-gram embeddings for the center word, compute dot products with context embeddings. Backward pass: distribute gradients back to each contributing n-gram.
def sigmoid(x):
"""Numerically stable sigmoid."""
return np.where(
x >= 0, 1.0 / (1.0 + np.exp(-x)), np.exp(x) / (1.0 + np.exp(x))
)
class FastText:
"""
FastText with character n-grams and negative sampling.
Training mirrors the paper's Skip-gram objective.
"""
def __init__(
self,
vocab,
embedding_dim=30,
min_n=3,
max_n=5,
num_negatives=5,
learning_rate=0.1,
):
self.vocab = vocab
self.word_to_idx = {w: i for i, w in enumerate(vocab)}
self.embedding_dim = embedding_dim
self.min_n = min_n
self.max_n = max_n
self.num_negatives = num_negatives
self.lr = learning_rate
# Context embeddings (one per vocabulary word)
self.ctx_embeddings = np.random.randn(len(vocab), embedding_dim) * 0.01
# Build n-gram index from vocabulary
self._build_ngram_index()
# Build uniform negative sampling distribution
self.neg_probs = np.ones(len(vocab)) / len(vocab)
def _get_ngrams(self, word):
return get_ngrams(word, self.min_n, self.max_n)
def _build_ngram_index(self):
all_ngs = set()
self.word_ngs = {}
for word in self.vocab:
ngs = self._get_ngrams(word)
self.word_ngs[word] = ngs
all_ngs.update(ngs)
self.ng_to_idx = {ng: i for i, ng in enumerate(sorted(all_ngs))}
# n-gram embedding matrix: rows = unique n-grams, cols = dimensions
self.ng_embeddings = (
np.random.randn(len(self.ng_to_idx), self.embedding_dim) * 0.01
)
# Precompute integer index lists per word for fast lookup
self.word_ng_idx = {
w: [self.ng_to_idx[ng] for ng in ngs]
for w, ngs in self.word_ngs.items()
}
def _word_embedding(self, word):
"""Sum n-gram embeddings for a word (OOV-safe)."""
if word in self.word_ng_idx:
idx = self.word_ng_idx[word]
else:
idx = [
self.ng_to_idx[ng]
for ng in self._get_ngrams(word)
if ng in self.ng_to_idx
]
if not idx:
return np.zeros(self.embedding_dim)
return self.ng_embeddings[idx].sum(axis=0)
def _sample_negatives(self, exclude_idx, k):
"""Sample k negative indices, excluding the positive context."""
negs = []
while len(negs) < k:
idx = np.random.choice(len(self.vocab), p=self.neg_probs)
if idx != exclude_idx and idx not in negs:
negs.append(idx)
return negs
def train_pair(self, center_word, ctx_idx):
"""
One SGD step on a (center_word, context_word) pair.
Returns the scalar loss for monitoring.
"""
center_emb = self._word_embedding(center_word)
ng_idx = self.word_ng_idx.get(
center_word,
[
self.ng_to_idx[ng]
for ng in self._get_ngrams(center_word)
if ng in self.ng_to_idx
],
)
ctx_emb = self.ctx_embeddings[ctx_idx]
pos_score = np.dot(center_emb, ctx_emb)
pos_sig = sigmoid(pos_score)
neg_idx = self._sample_negatives(ctx_idx, self.num_negatives)
neg_embs = self.ctx_embeddings[neg_idx]
neg_scores = neg_embs @ center_emb
neg_sigs = sigmoid(-neg_scores)
loss = -np.log(pos_sig + 1e-10) - np.sum(np.log(neg_sigs + 1e-10))
# Gradient for center word (flows back to all n-grams)
grad_center = (pos_sig - 1.0) * ctx_emb
for i, ni in enumerate(neg_idx):
grad_center += (1.0 - neg_sigs[i]) * self.ctx_embeddings[ni]
for i in ng_idx:
self.ng_embeddings[i] -= self.lr * grad_center
# Gradient for positive context embedding
self.ctx_embeddings[ctx_idx] -= self.lr * (pos_sig - 1.0) * center_emb
# Gradients for negative context embeddings
for i, ni in enumerate(neg_idx):
self.ctx_embeddings[ni] -= (
self.lr * (1.0 - neg_sigs[i]) * center_emb
)
return float(loss)
def get_embedding(self, word):
return self._word_embedding(word.lower())
def most_similar(self, word, top_n=5):
query = self.get_embedding(word)
qnorm = np.linalg.norm(query)
if qnorm == 0:
return []
query_unit = query / qnorm
sims = []
for vw in self.vocab:
if vw.lower() == word.lower():
continue
e = self.get_embedding(vw)
n = np.linalg.norm(e)
if n > 0:
sims.append((vw, float(np.dot(query_unit, e / n))))
sims.sort(key=lambda x: x[1], reverse=True)
return sims[:top_n]The train_pair method is the heart of the implementation. Notice that the gradient for the center word, grad_center, is computed once and then applied identically to every n-gram embedding in the word. This is the mathematical consequence of the sum-of-embeddings representation: the gradient of the loss with respect to each is the same vector, because each n-gram contributes equally to the dot product with the context embedding. In practice, FastText's actual C++ implementation accumulates this gradient and applies it in a single matrix operation for efficiency, but the mathematical meaning is identical.
Training on a Morphological Corpus
We train on a small corpus specifically designed to surface morphological relationships. Each sentence places a root form near its inflected variants, so the model must learn that "run," "running," and "runner" are contextually interchangeable:
training_sentences = [
"run running runner runs",
"walk walking walker walks",
"swim swimming swimmer swims",
"jump jumping jumper jumps",
"the runner runs fast",
"the walker walks slowly",
"the swimmer swims well",
"the jumper jumps high",
"running is great exercise",
"walking is healthy activity",
"swimming builds endurance quickly",
"jumping improves fitness levels",
"fast runners win more races",
"good swimmers train every day",
"high jumpers need strong legs",
"daily walkers stay healthier longer",
]
all_words = " ".join(training_sentences).lower().split()
vocab = sorted(set(all_words))
word_to_idx = {w: i for i, w in enumerate(vocab)}
def generate_pairs(sentences, w2i, window=2):
pairs = []
for sent in sentences:
words = sent.lower().split()
for i, center in enumerate(words):
lo = max(0, i - window)
hi = min(len(words), i + window + 1)
for j in range(lo, hi):
if j != i:
pairs.append((center, w2i[words[j]]))
return pairs
training_pairs = generate_pairs(training_sentences, word_to_idx)
np.random.seed(42)
model = FastText(
vocab=vocab,
embedding_dim=30,
min_n=3,
max_n=5,
num_negatives=5,
learning_rate=0.1,
)
epochs = 100
losses = []
for epoch in range(epochs):
np.random.shuffle(training_pairs)
epoch_loss = sum(model.train_pair(cw, ci) for cw, ci in training_pairs)
losses.append(epoch_loss / len(training_pairs))FastText training summary: -------------------------------------------------- Vocabulary size: 50 Unique n-grams: 518 Training pairs: 176 Epochs: 100 Initial loss: 4.0795 Final loss: 2.1277 Reduction: 47.8%
The model learns quickly on this small corpus. The 75-80% loss reduction shows that the model now assigns high compatibility scores to word pairs that co-occur in the training sentences. Notice that the unique n-gram count is much smaller than the word count times the per-word n-gram count, because n-grams are shared across words. The n-gram "walk" appears in "walk," "walking," "walker," and "walks," so it is counted once in the index even though it appears in four vocabulary words.

What the Model Learned
After training, we can examine the similarity structure to verify that the model learned morphological groupings. The key test is whether words from the same morphological family (all forms of "run," all forms of "swim," and so on) ended up near each other, while words from different families remained distant.
from numpy.linalg import norm
def pairwise_sim_matrix(model, words):
embs = [model.get_embedding(w) for w in words]
n = len(words)
mat = np.zeros((n, n))
for i in range(n):
for j in range(n):
ni, nj = norm(embs[i]), norm(embs[j])
if ni > 0 and nj > 0:
mat[i, j] = float(np.dot(embs[i], embs[j]) / (ni * nj))
return mat
compare_words = [
"run",
"running",
"runner",
"swim",
"swimming",
"swimmer",
"walk",
"walking",
"fast",
"slow",
]
# Before training: fresh model with same architecture
np.random.seed(99)
before_model = FastText(vocab=vocab, embedding_dim=30, min_n=3, max_n=5)
before_sim = pairwise_sim_matrix(before_model, compare_words)
after_sim = pairwise_sim_matrix(model, compare_words)

The post-training matrix reveals exactly the structure the architecture was designed to produce. Within each morphological family, similarities are high. Across families, they drop. The white grid lines in the heatmap visually separate the four groups (run family, swim family, walk family, and the pair of adverbs), making the block structure apparent. The n-gram sharing is the mechanism, but training is what turns shared structure into meaningful proximity.
The before-training matrix already shows mild positive similarities within families because shared n-grams produce non-zero dot products even with random weights. Training amplifies this pre-existing signal rather than creating it from scratch, which is one reason FastText converges faster than models that start from purely random per-word embeddings.
Handling Out-of-Vocabulary Words
The OOV case is where FastText's design pays off most dramatically. Let's test words the model never saw during training. For an OOV word, the model computes its embedding by extracting n-grams, looking up any that appear in the n-gram table (which was built from training vocabulary words), and summing their embeddings. N-grams not in the table are simply skipped.
oov_words = ["runs", "walked", "swimmers", "jumped", "runing"]
in_vocab_set = set(vocab)
oov_results = {}
for word in oov_words:
emb = model.get_embedding(word)
sim_words = model.most_similar(word, top_n=3)
oov_results[word] = {
"in_vocab": word in in_vocab_set,
"emb_norm": float(norm(emb)),
"similar": sim_words,
}Out-of-vocabulary word handling:
-------------------------------------------------------
'runs' (in vocab):
Embedding norm: 7.8330
Most similar: [('run', 0.852), ('runner', 0.69), ('runners', 0.576)]
'walked' (OOV):
Embedding norm: 4.5201
Most similar: [('walks', 0.797), ('walker', 0.775), ('walk', 0.695)]
'swimmers' (in vocab):
Embedding norm: 11.4488
Most similar: [('day', 0.816), ('every', 0.57), ('train', 0.49)]
'jumped' (OOV):
Embedding norm: 4.4676
Most similar: [('jumps', 0.871), ('jumper', 0.79), ('jump', 0.768)]
'runing' (OOV):
Embedding norm: 5.5047
Most similar: [('running', 0.906), ('run', 0.864), ('the', 0.694)]OOV words receive meaningful, non-trivial embeddings and find their morphological relatives through nearest-neighbor search. "Walked" (past tense, absent from training) correctly identifies "walk," "walking," and "walker" as its nearest neighbors because the 3-gram "wal" and 4-gram "walk" appear in all of them. "Runing" (a typo for "running") finds "running" at the top because they differ by only one character and share nearly all n-grams.
This robustness to typos degrades gracefully with edit distance. A single-character change preserves most n-grams; two changes preserve fewer; severe distortions eventually produce embeddings only weakly related to the intended word. The degradation is smooth rather than catastrophic, which means FastText handles real-world input noise far more gracefully than word-level models, which fail completely on any deviation from known forms.
An important subtlety: the quality of the OOV embedding depends on how many of its n-grams were seen during training. A word that shares n-grams with many training words will get a rich embedding. A word from an entirely different language or script may produce an embedding of near-zero norm if none of its n-grams appear in the table. This is a meaningful signal: a low-norm OOV embedding indicates a word that the model has no basis for representing.

Visualizing the Learned Embedding Space
A PCA projection of the trained embeddings shows whether morphological families ended up near each other in the full embedding space. PCA finds the directions of maximum variance in the embedding matrix and projects each vector onto the first two principal components, producing a 2D view that preserves as much structure as possible from the original 30-dimensional space.
from sklearn.decomposition import PCA
viz_words = [
"run",
"running",
"runner",
"runing",
"walk",
"walking",
"walker",
"walked",
"swim",
"swimming",
"swimmer",
"swiming",
"jump",
"jumping",
"jumper",
"jumped",
]
# The fourth form in each family is absent from the training vocabulary.
oov_in_viz = {w for w in viz_words if w not in in_vocab_set}
emb_list, valid_words = [], []
for w in viz_words:
e = model.get_embedding(w)
if norm(e) > 0:
emb_list.append(e)
valid_words.append(w)
emb_arr = np.array(emb_list)
coords_2d = PCA(n_components=2).fit_transform(emb_arr)
family_colors = {
"run": theme_color("#1f77b4"),
"running": theme_color("#1f77b4"),
"runner": theme_color("#1f77b4"),
"runing": theme_color("#1f77b4"),
"walk": theme_color("#2ca02c"),
"walking": theme_color("#2ca02c"),
"walker": theme_color("#2ca02c"),
"walked": theme_color("#2ca02c"),
"swim": theme_color("#d62728"),
"swimming": theme_color("#d62728"),
"swimmer": theme_color("#d62728"),
"swiming": theme_color("#d62728"),
"jump": theme_color("#9467bd"),
"jumping": theme_color("#9467bd"),
"jumper": theme_color("#9467bd"),
"jumped": theme_color("#9467bd"),
}
OOV words land within or near the correct family regions despite never having been seen during training. The PCA projection confirms that the n-gram sharing mechanism is doing meaningful work: structural similarity in character sequences translates into proximity in the learned embedding space. When the model has never seen "runing" as a complete token, it can still place it near "run," "running," and "runner" because most of its n-grams are familiar from training on related words.
PCA preserves some structure and loses some. The first two principal components capture the directions of maximum variance, which in this case align with the between-family separation axis and within-family variation. The actual embedding space is 30-dimensional, and some structure that exists there is invisible in the 2D projection. The clusters you see are real, but the distances between clusters in 2D are not perfectly representative of distances in 30 dimensions.
Hashing N-grams for Memory Efficiency
Our implementation maintains one embedding vector per unique n-gram. For a small corpus that is fine, but for production-scale vocabularies this fails. A vocabulary of 100,000 words with n-grams of length 3 to 6 can generate several million unique n-grams. At 300 dimensions and 4 bytes per float, that exceeds 2 GB of memory before counting word embeddings. No single inference machine can load this into RAM efficiently.
FastText addresses this with hash bucketing. Rather than allocating a unique embedding for each n-gram, the model hashes each n-gram to one of buckets and stores an embedding per bucket. The memory usage is then exactly bytes regardless of how many unique n-grams exist in the vocabulary.
N-gram hashing maps each character n-gram to a bucket index via a deterministic hash function (typically FNV-1a, which is fast and produces well-distributed outputs). When multiple n-grams hash to the same bucket, they share a single embedding vector. This is called a hash collision. The number of collisions depends on the ratio of unique n-grams to buckets: at 2 million buckets for a typical English model, most buckets contain exactly one n-gram, keeping collision rates acceptably low. The key insight is that the hash function is fixed before training and never changes, so inference is deterministic.
The hashing trick was popularized in machine learning by the "feature hashing" or "hashing trick" paper, and FastText applies it directly to n-gram storage. The approach works because hash collisions are rare when the number of buckets is large relative to the number of distinct n-grams, and when collisions do occur, they cause mild accuracy degradation rather than catastrophic failures. Two unrelated n-grams sharing a bucket means their embeddings are conflated, which adds noise but does not prevent learning.
class HashedFastText:
"""
FastText with hash bucketing for memory-bounded n-gram storage.
"""
def __init__(
self, vocab, embedding_dim=50, min_n=3, max_n=6, num_buckets=10000
):
self.embedding_dim = embedding_dim
self.min_n = min_n
self.max_n = max_n
self.num_buckets = num_buckets
# One embedding per bucket instead of one per n-gram
self.bucket_embeddings = (
np.random.randn(num_buckets, embedding_dim) * 0.01
)
def _hash(self, ngram):
return hash(ngram) % self.num_buckets
def _get_ngrams(self, word):
return get_ngrams(word, self.min_n, self.max_n)
def get_embedding(self, word):
bucket_idx = [self._hash(ng) for ng in self._get_ngrams(word)]
return self.bucket_embeddings[bucket_idx].sum(axis=0)
# Memory comparison
np.random.seed(42)
standard_ft_mem = FastText(vocab=vocab, embedding_dim=50, min_n=3, max_n=5)
n_unique_ngrams = len(standard_ft_mem.ng_to_idx)
standard_mem_bytes = n_unique_ngrams * 50 * 8 # float64
hashed_ft_mem = HashedFastText(
vocab=vocab, embedding_dim=50, min_n=3, max_n=5, num_buckets=1000
)
hashed_mem_bytes = hashed_ft_mem.num_buckets * 50 * 8Memory comparison: standard vs hashed n-gram storage
-------------------------------------------------------
Vocabulary size: 50
Standard FastText:
Unique n-grams: 518
Memory: 202.3 KB
Hashed FastText (1,000 buckets):
Buckets: 1,000
Memory: 390.6 KB
Savings: -93.1%
At production scale (100K vocabulary, 2M buckets):
Standard (est.): 2289 MB
Hashed (2M buckets): 2289 MB
Savings: 0.0%
Hashing introduces a modest accuracy trade-off: colliding n-grams share embeddings, which can conflate unrelated substrings. In practice, at 2 million buckets, collision rates for typical English are well below 5%, and empirical benchmarks show negligible impact on downstream task quality. The memory savings are dramatic and allow FastText models to be deployed on machines with limited RAM, including mobile devices and embedded systems.
One practical consideration when choosing the bucket count: the buckets are always allocated, even if many are empty. So choosing 2 million buckets commits to GB of RAM regardless of whether the vocabulary is large or small. For small vocabularies, a smaller bucket count is appropriate. The rule of thumb from the paper's experiments is to use at least as many buckets as there are unique n-grams in the training vocabulary, which avoids collisions almost entirely while still bounding memory at a fixed size.
FastText for Morphologically Rich Languages
English morphology is relatively shallow: a handful of common suffixes cover most of the grammatical variation, and the number of distinct forms per word is modest. The real demonstration of FastText's power is on languages where morphology is far more productive, where the number of distinct word forms per root can run into the thousands.
German builds compound nouns without spaces. "Lebensversicherungsgesellschaft" (life insurance company) joins three meaningful roots: "Leben" (life), "Versicherung" (insurance), and "Gesellschaft" (company). A Word2Vec model will never see this exact compound in most corpora. FastText, however, will share n-grams between the compound and its components, encoding the compositional meaning in the embedding automatically. The n-gram "leben" (from "Leben") will match both the standalone word and the compound prefix. The n-gram "gesellschaft" will match the component and the compound suffix.
This compositional encoding is not perfect. FastText does not know where one root ends and another begins inside the compound. But it does not need to: the n-gram overlap gives a rough approximation of the compositional structure, which is enough to produce useful embeddings even for compounds that appear only once in a billion-word corpus.
german_compounds = [
("Lebensversicherung", "life insurance", ["Leben", "Versicherung"]),
("Krankenhaus", "hospital", ["Krank", "Haus"]),
("Handschuh", "glove", ["Hand", "Schuh"]),
("Kindergarten", "kindergarten", ["Kinder", "Garten"]),
]
def compound_overlap(compound, components, min_n=3, max_n=5):
"""Fraction of compound's n-grams that appear in any component."""
compound_ngs = set(get_ngrams(compound.lower(), min_n, max_n))
component_ngs = set()
for c in components:
component_ngs.update(get_ngrams(c.lower(), min_n, max_n))
shared = compound_ngs & component_ngs
return len(compound_ngs), len(shared), sorted(shared)[:5]
analysis = []
for compound, meaning, components in german_compounds:
total, shared_n, examples = compound_overlap(compound, components)
analysis.append(
{
"compound": compound,
"meaning": meaning,
"components": components,
"total": total,
"shared": shared_n,
"overlap_pct": 100 * shared_n / total,
"examples": examples,
}
)N-gram overlap: German compounds with their components
------------------------------------------------------------
Lebensversicherung (life insurance)
Components: Leben + Versicherung
Compound n-grams: 51
Shared with components: 39 (76%)
Shared examples: ['<le', '<leb', '<lebe', 'ben', 'che']
Krankenhaus (hospital)
Components: Krank + Haus
Compound n-grams: 30
Shared with components: 15 (50%)
Shared examples: ['<kr', '<kra', '<kran', 'ank', 'aus']
Handschuh (glove)
Components: Hand + Schuh
Compound n-grams: 24
Shared with components: 15 (62%)
Shared examples: ['<ha', '<han', '<hand', 'and', 'chu']
Kindergarten (kindergarten)
Components: Kinder + Garten
Compound n-grams: 33
Shared with components: 24 (73%)
Shared examples: ['<ki', '<kin', '<kind', 'art', 'arte']Every German compound shares a substantial fraction of its n-grams with its component roots. "Krankenhaus" (hospital) shares about 60% of its n-grams with "Krank" (sick) and "Haus" (house). A FastText model trained on a German corpus will place "Krankenhaus" in a region of embedding space influenced by both components' embeddings, even if the compound never appears in training. The embedding captures compositional meaning for free, without any explicit morphological analyzer or dictionary lookup.
The same logic applies across a wide range of languages and linguistic phenomena:
-
Turkish and Finnish (agglutinative morphology): These languages stack grammatical suffixes onto roots in long chains. "Evlerinizden" (from your houses) in Turkish shares n-grams with "ev" (house) and related suffixed forms. FastText handles new suffix combinations naturally because it has seen the individual suffix n-grams many times during training.
-
Arabic and Hebrew (templatic morphology): These languages use a three-consonant root system where a single root interleaves with vowel patterns to produce different word classes and tenses. Character n-grams capture the consonant sequences that encode root meaning, even though the vowel patterns change between forms.
-
Any language with productive affixation: Medical and scientific terminology across all languages uses Latin and Greek roots that recombine in predictable ways. "Electroencephalography" shares n-grams with "electro," "encephalo," "graph," and their Latin derivatives, giving FastText a way to anchor even unfamiliar coinages in a meaningful region of embedding space.
A Worked Example: Morphological Generalization
To make the cross-lingual benefit concrete, consider what happens when a FastText model trained on Turkish encounters the word "evlerinizden" (from your houses), which might not appear in the training corpus at all. The word decomposes into the root "ev" (house), followed by the plural suffix "-ler," the possessive suffix "-iniz" (your), and the ablative case suffix "-den" (from). Each suffix appears in hundreds of other Turkish words in training. The n-grams "<evl," "evle," "vler," "leri," "erin," "riniz" and others appear in related words. The embedding FastText assigns to this never-seen word will be a blend of embeddings it has seen from "ev," from other words ending in "-den," from other words containing "iniz," and so on. The result is imperfect but meaningful, far better than the zero-vector fallback that Word2Vec would provide.
This is also why FastText was a significant advance for low-resource language processing. Languages with small Wikipedia corpora but rich morphology, like Swahili or Icelandic, have relatively few unique surface forms in any given training corpus, but those forms cover a much larger space of meanings through morphological combination. FastText exploits this structure to learn embeddings that generalize across the morphological space, making it a practical choice even when data is scarce.
Using Pretrained FastText Models
Training FastText from scratch requires a large corpus and substantial compute time. For most applications, a better starting point is one of the pretrained models that Meta (Facebook AI Research) distributes publicly. These cover 157 languages and were trained on Wikipedia and Common Crawl text, giving broad coverage across both formal and informal registers.
The fasttext Python package provides a clean interface for loading and querying these models. The steps below show the main operations you will use in practice:
# Install the library (run once in your environment):
# uv pip install fasttext-wheel
import fasttext
import fasttext.util
# Download the English model (this downloads ~4GB of data on first run):
fasttext.util.download_model("en", if_exists="ignore")
ft_en = fasttext.load_model("cc.en.300.bin")
# Query word vectors, including OOV words:
vec_running = ft_en.get_word_vector("running") # shape: (300,)
vec_runing = ft_en.get_word_vector("runing") # OOV typo, still gets a vector
# Find nearest neighbors in the pretrained space:
neighbors = ft_en.get_nearest_neighbors("runner", k=5)
# Returns list of (similarity, word) tuples# Reduce dimensionality for memory efficiency (optional):
# fasttext.util.reduce_model(ft_en, 100) # reduce from 300 to 100 dimensions
# ft_en.save_model('cc.en.100.bin')
# Cross-lingual: load models for multiple languages and align them
# (requires MUSE alignment matrices, see Meta's multilingual tools)The pretrained models use the full production settings: 300-dimensional embeddings, 2 million hash buckets, n-gram range 3 to 6, and training on datasets orders of magnitude larger than our toy example. The nearest-neighbor results from these models reflect semantic and morphological relationships captured from billions of tokens of text.
One practical note: the .bin format includes the full n-gram table needed for OOV inference. If you only need embeddings for known vocabulary words, you can use the .vec format, which is a simple text file of word-to-vector mappings. The .vec format is much smaller (a few hundred MB versus several GB) but loses OOV capability entirely.
Limitations and What Comes Next
FastText advances Word2Vec, but it inherits one critical limitation from the static embedding paradigm and introduces one new concern of its own.
Context Independence: The Polysemy Problem
Every word has exactly one embedding regardless of which sentence it appears in. The word "bank" receives the same vector whether you write "the river bank" or "the bank account." FastText has no mechanism to distinguish these senses because the embedding is computed from character n-grams alone, with no awareness of the surrounding text. This is not a flaw in FastText specifically but a property of all static embedding methods: the representation is computed once per word, offline, before any inference happens.
The problem is real and consequential. Polysemous words are extremely common in English and other natural languages. WordNet catalogs an average of 2.6 senses per noun. For words in the long tail of sense distributions, a single averaged vector is a poor representation of any individual sense. Downstream models that use static embeddings must implicitly recover the correct sense from context, which places an extra burden on the task-specific model and limits how much of the semantic signal the embeddings can provide.
The long-term solution to context independence came from a different direction. ELMo, introduced in 2018, used bidirectional LSTMs to produce word representations that depend on the entire sentence context. The same word in different sentences produces different vectors. BERT extended this with the transformer architecture, producing context-sensitive representations that captured word senses, syntactic roles, coreference, and other discourse-level phenomena. FastText embeddings and BERT contextual embeddings are designed for different situations: FastText is fast, memory-efficient, and well-suited to morphologically rich languages, while BERT produces richer representations at the cost of much higher compute and memory requirements.
Noise Without Linguistic Meaning
The n-gram mechanism cannot distinguish meaningful morphological segments from arbitrary character sequences. "xyzqwk" produces a non-zero embedding by summing its n-gram vectors, even though no human would recognize it as a word from any known language. Applications that rely on zero-norm vectors to signal unknown tokens must add explicit out-of-vocabulary detection logic. In practice this is an inconvenience rather than a fundamental problem, but it matters when deploying FastText in a pipeline that needs to flag anomalous inputs.
Related to this: FastText embeddings for very short words (two or three characters) can be low quality because short words have few n-grams. The word "a" with the default minimum of 3 and maximum of 6 produces just the single n-gram "<a>," which gives it almost no representational capacity. Short function words in general tend to be handled less well by FastText than by word-level models, which is the reverse of the situation for long morphologically complex words.
Memory and Deployment Considerations
Even with hashing, storing 2 million 300-dimensional float vectors requires about 2.4 GB for the n-gram table alone. The full binary models released by Meta can exceed 7 GB when word-level embeddings are included. This makes cold-start deployment on mobile devices or in memory-constrained environments challenging. Techniques like product quantization (compressing each embedding vector into a small code) and model distillation (training a smaller model to mimic the larger one) are commonly used to reduce the footprint.
The 300-dimensional default also means that every embedding operation involves multiplying or adding 300-dimensional vectors, which is fast on modern hardware but adds up when processing millions of documents in a real-time pipeline. For latency-sensitive applications, smaller dimensionality (50 or 100 dimensions) often provides a good trade-off between speed and accuracy.
Logographic and Non-alphabetic Scripts
Character n-grams assume that subword character sequences carry meaning. This assumption is sound for alphabetic and syllabic writing systems, where characters map to sounds and sounds map to meaning. But for Chinese, Japanese kanji, and other logographic systems, individual characters often map directly to morphemes or entire words. The n-gram of two adjacent characters may be entirely meaningless as a linguistic unit, or it may be a compound word, with no predictable relationship between character position and meaning. Chinese NLP typically uses word-level tokenization (with dedicated Chinese word segmentation tools) or character-level models rather than FastText's fixed-length n-gram approach. Specialized methods exist for these scripts, and FastText's published results for Chinese and Japanese are generally weaker than for alphabetic languages.
Despite these limitations, FastText occupies an important and durable niche. For downstream tasks on morphologically rich languages, for applications where OOV robustness matters, and for deployment scenarios where a compact static embedding model is preferred over a large contextual model, FastText consistently outperforms Word2Vec with modest additional computational cost. Pretrained FastText models for 157 languages are freely available from Meta, making it easy to use without training from scratch.
Summary
FastText extends Word2Vec by replacing word-level atomic representations with sums of character n-gram embeddings. This single architectural change yields three practical benefits: handling of out-of-vocabulary words, morphological awareness through shared n-gram structure, and less brittleness to typos and noisy text. The training objective remains skip-gram with negative sampling, but gradients now flow back to all n-gram embeddings in a center word, allowing shared learning across morphological variants. The hashing trick bounds memory usage at a fixed bucket count regardless of vocabulary size. This makes deployment practical at scale.
Key takeaways from this chapter:
- Subword representation: Each word is decomposed into character n-grams (default lengths 3 to 6), with boundary markers separating positional from interior contexts
- Embedding computation: A word's embedding is the sum of its n-gram embeddings, plus an optional word-level term for known vocabulary words
- OOV handling: Any word can receive an embedding by summing whatever n-gram embeddings were learned, enabling zero-shot generalization to unseen words, typos, and morphological variants
- Morphological awareness: Words sharing the same root share n-grams, so morphological variants naturally cluster in embedding space even for words never seen during training
- Hash bucketing: N-grams are hashed to a fixed number of buckets to control memory usage, trading minor accuracy for large memory savings at production scale
- Language applicability: FastText excels on morphologically rich alphabetic languages but has limitations for logographic scripts like Chinese
- Context independence: Like all static embeddings, FastText cannot distinguish word senses from context, which limits its ability to represent polysemous words accurately
The next chapter on embedding evaluation will give us tools to measure these properties quantitatively: how to test similarity, how to assess OOV coverage, and how to detect when embedding quality is insufficient for a downstream task. The chapter after that introduces contextual embeddings with ELMo, which addresses the context-independence limitation at the cost of higher computational complexity.
Key Parameters
The key parameters for FastText training are:
-
min_n: Minimum n-gram length. The default value of 3 captures short prefixes and suffixes. Setting it to 2 includes bigrams like "th" and "ed," which helps for very short words but roughly doubles the n-gram count and can add noisy, low-information substrings to many embeddings.
-
max_n: Maximum n-gram length. The default value of 6 balances morpheme capture with vocabulary size. For agglutinative languages with longer suffixes (Turkish, Finnish, Hungarian), values of 7 or 8 improve coverage of long morphological patterns.
-
bucket: Number of hash buckets for n-gram storage. The default of 2,000,000 keeps collision rates below 5% for typical English vocabularies. Reducing this saves memory but increases collisions; values below 100,000 noticeably degrade embedding quality. For small datasets, 500,000 to 1,000,000 is often sufficient.
-
dim: Embedding dimensionality. Values of 100 to 300 are standard. Lower dimensions train faster and generalize better on small datasets; higher dimensions capture more nuance but require more data to fill the representational space without overfitting.
-
epoch: Training passes over the corpus. The range of 5 to 25 covers most use cases. More epochs help on smaller datasets; for very large corpora (Common Crawl scale), even 1 epoch provides enough training signal for high-quality embeddings.
-
lr: Learning rate. The default of 0.05 works well with the linear decay schedule FastText uses by default, which reduces the learning rate linearly to zero over training. Higher values speed training but risk overshooting on small or noisy corpora.
-
neg: Number of negative samples per positive pair. Values of 5 to 10 are standard. Higher values improve training quality for rare words at the cost of slower training. For very large corpora, 5 negative samples is usually sufficient.
-
ws: Context window size. The default of 5 captures sentence-level co-occurrence. Smaller windows capture tight syntactic relationships (useful for part-of-speech tagging and named entity recognition); larger windows capture broader topical associations (useful for document classification and information retrieval).
Quiz
Ready to test your understanding? Take this quick quiz to reinforce what you've learned about FastText and character n-gram embeddings.
FastText Quiz
Reference
Citation details
Cite or share this article.
Continue with the full handbook
This chapter is part of Language AI Handbook. Use the handbook page to browse the complete table of contents and continue reading in sequence.
Explore Language AI HandbookStay up to date
Get articles, book updates, and news delivered to your inbox.
No spam, unsubscribe anytime.
Join the community
Sign in to remove popups, track your reading progress, and join the discussion.

Comments
No comments yet. Be the first to share your thoughts!