Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

language_model.py

36_nlp_basics/language_model.py  ·  188 lines  ·  view on GitHub

"""
N-gram Language Model with Laplace Smoothing
Complete implementation
"""
import numpy as np
from collections import Counter, defaultdict
from typing import List, Dict, Tuple

class NGramLanguageModel:
    """
    N-gram Language Model
    
    Models probability of next word given previous n-1 words:
    P(wᵢ|wᵢ₋ₙ₊₁, ..., wᵢ₋₁)
    
    For bigram (n=2): P(wᵢ|wᵢ₋₁)
    For trigram (n=3): P(wᵢ|wᵢ₋₂, wᵢ₋₁)
    """
    
    def __init__(self, n: int = 2, smoothing: bool = True, k: float = 1.0):
        """
        Args:
            n: N-gram size (2=bigram, 3=trigram, etc.)
            smoothing: Use Laplace smoothing
            k: Smoothing parameter (k=1 is Laplace, k=0.5 is less aggressive)
        """
        self.n = n
        self.smoothing = smoothing
        self.k = k
        self.ngram_counts = Counter()
        self.context_counts = Counter()
        self.vocabulary = set()
        self.total_words = 0
    
    def train(self, documents: List[List[str]]):
        """
        Train n-gram model on documents
        
        Steps:
        1. Add start/end markers
        2. Create n-grams
        3. Count n-grams and contexts
        4. Build vocabulary
        """
        for doc in documents:
            # Add start markers (n-1 markers for n-gram)
            if self.n > 1:
                doc = ['<START>'] * (self.n - 1) + doc + ['<END>']
            
            # Create n-grams
            for i in range(len(doc) - self.n + 1):
                ngram = tuple(doc[i:i+self.n])
                context = ngram[:-1] if self.n > 1 else tuple()
                
                self.ngram_counts[ngram] += 1
                if self.n > 1:
                    self.context_counts[context] += 1
                else:
                    self.total_words += 1
                
                # Add to vocabulary
                self.vocabulary.update(ngram)
    
    def probability(self, word: str, context: Tuple[str, ...] = None) -> float:
        """
        Compute probability P(word|context)
        
        Without smoothing:
        P(w|context) = count(context, w) / count(context)
        
        With smoothing (add-k):
        P(w|context) = (count(context, w) + k) / (count(context) + k*V)
        """
        if self.n == 1:
            # Unigram: P(w) = count(w) / total
            count = sum(1 for ngram in self.ngram_counts.keys() if ngram[0] == word)
            
            if self.smoothing:
                V = len(self.vocabulary)
                return (count + self.k) / (self.total_words + self.k * V)
            else:
                return count / self.total_words if self.total_words > 0 else 0.0
        
        else:
            # N-gram: P(w|context)
            if context is None:
                context = tuple()
            
            ngram = context + (word,)
            count = self.ngram_counts[ngram]
            context_count = self.context_counts[context]
            
            if self.smoothing:
                V = len(self.vocabulary)
                return (count + self.k) / (context_count + self.k * V)
            else:
                return count / context_count if context_count > 0 else 0.0
    
    def predict_next_word(self, context: Tuple[str, ...], top_k: int = 5) -> List[Tuple[str, float]]:
        """
        Predict most likely next words given context
        
        Returns top-k words with their probabilities
        """
        probs = []
        for word in self.vocabulary:
            if word not in ['<START>', '<END>']:
                prob = self.probability(word, context)
                probs.append((word, prob))
        
        # Sort by probability (descending)
        probs.sort(key=lambda x: x[1], reverse=True)
        return probs[:top_k]
    
    def generate_text(self, start_words: List[str], max_length: int = 10) -> List[str]:
        """
        Generate text using n-gram model
        
        Steps:
        1. Start with given words
        2. Predict next word using context
        3. Add to sequence
        4. Repeat until max_length or <END>
        """
        if len(start_words) < self.n - 1:
            start_words = ['<START>'] * (self.n - 1 - len(start_words)) + start_words
        
        generated = start_words.copy()
        
        for _ in range(max_length):
            # Get context (last n-1 words)
            context = tuple(generated[-(self.n-1):]) if self.n > 1 else tuple()
            
            # Predict next word
            next_words = self.predict_next_word(context, top_k=1)
            if not next_words:
                break
            
            next_word = next_words[0][0]
            if next_word == '<END>':
                break
            
            generated.append(next_word)
        
        return generated


# Usage Example
if __name__ == "__main__":
    print("N-gram Language Model")
    print("=" * 60)
    
    # Training data
    documents = [
        ["the", "cat", "sat", "on", "the", "mat"],
        ["the", "dog", "sat", "on", "the", "mat"],
        ["the", "cat", "and", "the", "dog"],
        ["the", "cat", "chased", "the", "dog"]
    ]
    
    print("Training data:")
    for doc in documents:
        print(f"  {' '.join(doc)}")
    print()
    
    # Train bigram model
    model = NGramLanguageModel(n=2, smoothing=True, k=1.0)
    model.train(documents)
    
    # Test predictions
    print("Predictions:")
    print("  P(cat|the) =", model.probability("cat", ("the",)))
    print("  P(dog|the) =", model.probability("dog", ("the",)))
    print("  P(bird|the) =", model.probability("bird", ("the",)), "(unseen, but > 0 with smoothing)")
    print()
    
    # Predict next words
    print("Most likely next words after 'the':")
    next_words = model.predict_next_word(("the",), top_k=3)
    for word, prob in next_words:
        print(f"  {word}: {prob:.4f}")
    print()
    
    # Generate text
    print("Generated text (starting with 'the'):")
    generated = model.generate_text(["the"], max_length=5)
    print(f"  {' '.join(generated)}")