Sunday, August 02, 2026

UNDERSTANDING NAMED ENTITY RECOGNITION: A JOURNEY FROM BASICS TO IMPLEMENTATION

 




INTRODUCTION TO THE WORLD OF NAMED ENTITY RECOGNITION

Imagine you are reading a news article that mentions "Apple announced a new product in Cupertino on Monday." As a human reader, you instantly understand that "Apple" refers to a company, "Cupertino" is a location, and "Monday" represents a date. This seemingly effortless ability to identify and categorize specific pieces of information is something computers struggle with unless they are specifically trained to do so. This is where Named Entity Recognition comes into play.


Named Entity Recognition, commonly abbreviated as NER, is a fundamental task in natural language processing that focuses on identifying and classifying named entities within text into predefined categories. These categories typically include person names, organizations, locations, dates, monetary values, percentages, and many other types of specific information that carry semantic meaning.


The importance of NER cannot be overstated in our modern digital world. Every day, billions of documents, articles, social media posts, and messages are created. Extracting structured information from this unstructured text is crucial for applications ranging from search engines to customer service chatbots, from medical record analysis to financial news monitoring. NER serves as a foundational building block for many advanced natural language processing applications.


THE FUNDAMENTAL PURPOSE OF NAMED ENTITY RECOGNITION

Before diving into the technical details, let us understand why NER exists and what problems it solves. In the realm of information processing, raw text is unstructured data. While humans can read and understand it easily, computers see text as merely sequences of characters. To make text useful for computational tasks, we need to extract structured information from it.


Consider a customer service scenario where a company receives thousands of emails daily. These emails mention product names, customer locations, dates of purchase, and complaint categories. Without NER, a human would need to read each email and manually extract this information. With NER, a system can automatically identify that "iPhone 15 Pro" is a product, "New York" is a location, and "October 15, 2024" is a date. This extracted information can then be used to route emails, generate reports, or trigger automated responses.


The purpose of NER extends beyond simple extraction. It enables semantic understanding of text, allowing machines to comprehend what specific pieces of information represent in the real world. This understanding forms the basis for more complex tasks such as question answering, information retrieval, knowledge graph construction, and relationship extraction.


THE CORE CONCEPTS UNDERLYING NAMED ENTITY RECOGNITION

To truly understand NER, we need to grasp several fundamental concepts that form its theoretical foundation. These concepts help us appreciate both the challenges and the solutions in this field.


The first concept is that of a "named entity" itself. A named entity is a real-world object that can be denoted with a proper name. This includes concrete entities like people, organizations, and locations, as well as abstract entities like dates, times, and monetary amounts. The key characteristic is that these entities refer to specific instances rather than general concepts. For example, "Microsoft" is a named entity referring to a specific company, while "software company" is a general concept.


The second concept involves entity boundaries. In text, determining where an entity begins and ends is not always straightforward. Consider the phrase "Bank of America Corporation." Is this one entity or multiple entities? The answer depends on context and domain knowledge. NER systems must learn to recognize these boundaries accurately, which involves understanding multi-word expressions and compound names.


The third concept is entity classification. Once an entity is identified, it must be assigned to a category. The categories used in NER are typically defined based on the application domain. Standard categories include person, organization, location, date, time, money, and percent. However, specialized domains might require additional categories such as disease, gene, drug for biomedical text, or product, brand, model for e-commerce applications.


The fourth concept is context dependency. The same word or phrase can represent different entity types depending on context. The word "Washington" could refer to a person (George Washington), a city (Washington D.C.), or a state (Washington State). NER systems must use surrounding context to disambiguate such cases.


THE ARCHITECTURE OF NAMED ENTITY RECOGNITION SYSTEMS

Understanding how NER systems are built requires examining their architectural components. Modern NER systems typically consist of several layers that work together to transform raw text into structured entity information.


The first layer is tokenization, which breaks text into individual units called tokens. These tokens are usually words, but can also include punctuation marks and special characters. Tokenization is crucial because it defines the granularity at which the system operates. 


Consider this simple example:


text = "Dr. Smith works at IBM in New York."

tokens = ["Dr.", "Smith", "works", "at", "IBM", "in", "New", "York", "."]


Notice how "Dr." is kept as a single token despite containing a period, and "New York" is split into two tokens. These decisions affect how the NER system processes the text.


The second layer involves feature extraction. For each token, the system extracts features that help predict whether it is part of a named entity and what type. Traditional features include the token itself, its capitalization pattern, whether it appears in a gazetteer (a list of known entities), its part-of-speech tag, and features from surrounding tokens. Modern deep learning approaches learn these features automatically from data.


The third layer is the classification or tagging layer. This is where the actual NER decisions are made. The system assigns labels to each token indicating whether it is part of an entity and what type. A common labeling scheme is the BIO scheme, where B indicates the beginning of an entity, I indicates inside an entity, and O indicates outside any entity. For example:


Token:  Dr.    Smith  works  at   IBM    in   New    York   .

Label:  O      B-PER  O      O    B-ORG  O    B-LOC  I-LOC  O


In this scheme, "Smith" is labeled as the beginning of a person entity, "IBM" as the beginning of an organization entity, and "New" and "York" as the beginning and inside of a location entity respectively.


The fourth layer handles post-processing and entity assembly. Since individual tokens are labeled, the system must combine consecutive tokens with compatible labels into complete entities. It must also resolve conflicts and apply domain-specific rules to refine the output.


APPROACHES TO IMPLEMENTING NAMED ENTITY RECOGNITION

Over the years, researchers and practitioners have developed various approaches to implementing NER systems, each with its own strengths and trade-offs. Understanding these approaches provides insight into how the field has evolved and what options are available for different use cases.


The earliest approach was rule-based NER, which relies on hand-crafted patterns and rules to identify entities. For instance, a rule might state that a capitalized word following "Mr." or "Mrs." is likely a person name, or that a sequence of digits followed by "Street" or "Avenue" indicates a location. While rule-based systems can achieve high precision in specific domains, they require extensive manual effort to create and maintain rules, and they struggle to generalize across different text types.


The second major approach is machine learning-based NER, which emerged as supervised learning techniques became more sophisticated. These systems learn patterns from annotated training data rather than relying on hand-crafted rules. Traditional machine learning approaches used algorithms like Hidden Markov Models, Maximum Entropy Models, and Conditional Random Fields. These methods require careful feature engineering, where domain experts design features that capture relevant patterns in the data.


Here is a conceptual example of how features might be extracted for a machine learning model:


def extract_features(token, position, tokens):

    features = {}

    features['token'] = token

    features['is_capitalized'] = token[0].isupper()

    features['is_all_caps'] = token.isupper()

    features['is_numeric'] = token.isdigit()

    features['prefix_2'] = token[:2]

    features['suffix_2'] = token[-2:]

    

    if position > 0:

        features['prev_token'] = tokens[position - 1]

    if position < len(tokens) - 1:

        features['next_token'] = tokens[position + 1]

        

    return features


The third and most recent approach leverages deep learning, particularly neural networks. Deep learning models can automatically learn relevant features from raw text, eliminating the need for manual feature engineering. Architectures like Bidirectional LSTMs, Transformers, and BERT-based models have achieved state-of-the-art performance on NER tasks. These models can capture complex patterns and long-range dependencies in text that traditional methods miss.


THE LABELING SCHEMES THAT MAKE NER POSSIBLE

A critical aspect of NER that deserves detailed attention is the labeling scheme used to annotate entities in text. The choice of labeling scheme affects both how training data is prepared and how the NER model makes predictions.


The simplest scheme is the IO scheme, which uses only two types of labels: I for inside an entity and O for outside. Each entity type gets its own I label, such as I-PER for person or I-LOC for location. While simple, this scheme cannot distinguish between consecutive entities of the same type.


The BIO scheme, also called IOB, addresses this limitation by adding a B label to mark the beginning of an entity. This allows the system to recognize when one entity ends and another of the same type begins. Consider the phrase "John and Mary went to Paris":


Token:  John   and   Mary   went  to   Paris

BIO:    B-PER  O     B-PER  O     O    B-LOC


Without the B label, "John and Mary" might be incorrectly treated as a single person entity.

The BIOES scheme (also called BILOU) provides even finer granularity by adding labels for single-token entities (S) and the end of multi-token entities (E). This scheme can be beneficial for certain models and datasets:


Token:  Dr.    Smith  works  at   IBM    in   New    York

BIOES:  S-TTL  S-PER  O      O    S-ORG  O    B-LOC  E-LOC


Here, "Dr." is a single-token title entity, "Smith" is a single-token person entity, and "New York" is a multi-token location entity with explicit beginning and end markers.


THE CHALLENGES THAT MAKE NER DIFFICULT

Despite decades of research and significant progress, NER remains a challenging task due to several inherent difficulties in natural language processing. Understanding these challenges helps us appreciate the sophistication required in modern NER systems.


The first major challenge is ambiguity. Many words and phrases can have multiple interpretations depending on context. As mentioned earlier, "Washington" could be a person, city, or state. Similarly, "Apple" could refer to the fruit or the technology company. Resolving such ambiguities requires understanding the broader context and sometimes even world knowledge.


The second challenge involves entity boundary detection. Determining where an entity begins and ends is not always obvious, especially for nested entities or complex multi-word expressions. Consider "University of California, Los Angeles." Is this one entity or multiple? Should "Los Angeles" be separately tagged as a location? Different annotation guidelines might provide different answers.


The third challenge is the variability in how entities are expressed. People can refer to the same entity in multiple ways. "International Business Machines," "IBM," "Big Blue," and "the company" might all refer to the same organization in a document. NER systems must learn to recognize these variations, and more advanced systems need to perform coreference resolution to link these mentions.


The fourth challenge is domain adaptation. An NER model trained on news articles might perform poorly on social media text, scientific papers, or legal documents. Each domain has its own vocabulary, writing style, and types of entities. Adapting models across domains often requires additional training data or transfer learning techniques.


The fifth challenge concerns rare and emerging entities. New organizations are founded, new products are launched, and new locations become relevant constantly. NER systems must be able to recognize entities they have never seen during training, which requires strong generalization capabilities.


BUILDING A SIMPLE NER SYSTEM: STEP BY STEP

To make these concepts concrete, let us walk through building a simple NER system step by step. We will start with the most basic components and gradually add sophistication. This will use Python as the implementation language due to its popularity in natural language processing.


The first step is to prepare our text data. We need to tokenize the text and prepare it for processing. Here is how we might implement basic tokenization:


import re


def simple_tokenize(text):

    # Split on whitespace and punctuation while keeping punctuation

    pattern = r'\w+|[^\w\s]'

    tokens = re.findall(pattern, text)

    return tokens


sample_text = "Apple Inc. was founded by Steve Jobs in Cupertino, California."

tokens = simple_tokenize(sample_text)

print(tokens)

# Output: ['Apple', 'Inc', '.', 'was', 'founded', 'by', 'Steve', 'Jobs', 'in', 'Cupertino', ',', 'California', '.']


The second step involves creating a simple rule-based recognizer for demonstration purposes. This will help us understand the logic before moving to more sophisticated approaches:


def simple_rule_based_ner(tokens):

    entities = []

    i = 0

    

    while i < len(tokens):

        # Simple rule: capitalized word after "Mr.", "Mrs.", "Dr." is a person

        if i > 0 and tokens[i-1] in ['Mr', 'Mrs', 'Dr'] and tokens[i][0].isupper():

            entities.append((tokens[i], 'PERSON', i))

        

        # Simple rule: sequence of capitalized words might be an organization

        elif tokens[i][0].isupper() and i < len(tokens) - 1:

            if tokens[i+1] in ['Inc', 'Corp', 'Ltd', 'LLC']:

                entities.append((tokens[i] + ' ' + tokens[i+1], 'ORGANIZATION', i))

                i += 1  # Skip next token as it's part of the entity

        

        # Simple rule: capitalized word after "in" might be a location

        elif i > 0 and tokens[i-1] == 'in' and tokens[i][0].isupper():

            entities.append((tokens[i], 'LOCATION', i))

        

        i += 1

    

    return entities


This simple rule-based system demonstrates the basic logic of entity recognition, though it is far too simplistic for real-world use.


The third step is to implement a feature-based approach. We extract features from each token that a machine learning model could use:


def extract_token_features(tokens, index):

    token = tokens[index]

    features = {

        'token': token.lower(),

        'is_first': index == 0,

        'is_last': index == len(tokens) - 1,

        'is_capitalized': token[0].isupper(),

        'is_all_caps': token.isupper(),

        'is_all_lower': token.islower(),

        'is_numeric': token.isdigit(),

        'is_alphanumeric': token.isalnum(),

        'prefix_1': token[0],

        'prefix_2': token[:2] if len(token) >= 2 else token,

        'prefix_3': token[:3] if len(token) >= 3 else token,

        'suffix_1': token[-1],

        'suffix_2': token[-2:] if len(token) >= 2 else token,

        'suffix_3': token[-3:] if len(token) >= 3 else token,

        'length': len(token),

    }

    

    # Add context features from previous token

    if index > 0:

        prev_token = tokens[index - 1]

        features['prev_token'] = prev_token.lower()

        features['prev_is_capitalized'] = prev_token[0].isupper()

    else:

        features['prev_token'] = '<START>'

        features['prev_is_capitalized'] = False

    

    # Add context features from next token

    if index < len(tokens) - 1:

        next_token = tokens[index + 1]

        features['next_token'] = next_token.lower()

        features['next_is_capitalized'] = next_token[0].isupper()

    else:

        features['next_token'] = '<END>'

        features['next_is_capitalized'] = False

    

    return features


These features capture various aspects of the token and its context that are useful for predicting entity labels.


TRAINING DATA AND ANNOTATION FOR NER

To train a machine learning-based NER system, we need annotated training data where entities are labeled. The quality and quantity of this training data significantly impact the performance of the resulting model.


Annotation involves human annotators reading text and marking the boundaries and types of entities. This process requires clear annotation guidelines to ensure consistency. For instance, should "New York City" be tagged as one location entity or should "New York" and "City" be separate? Should job titles like "President" be tagged as titles or ignored? These decisions must be documented in annotation guidelines.

Here is an example of how annotated training data might be represented in code:


training_data = [

    {

        'text': 'Apple Inc. was founded by Steve Jobs.',

        'entities': [

            {'start': 0, 'end': 10, 'label': 'ORG', 'text': 'Apple Inc.'},

            {'start': 26, 'end': 37, 'label': 'PER', 'text': 'Steve Jobs'}

        ]

    },

    {

        'text': 'Microsoft announced a new product in Seattle.',

        'entities': [

            {'start': 0, 'end': 9, 'label': 'ORG', 'text': 'Microsoft'},

            {'start': 37, 'end': 44, 'label': 'LOC', 'text': 'Seattle'}

        ]

    }

]


This data structure represents the raw text along with the positions and labels of entities within it. Converting this to the BIO format for training would involve aligning the entity spans with tokenized text:


def convert_to_bio_format(text, entities, tokens):

    # Create a mapping of character positions to tokens

    bio_labels = ['O'] * len(tokens)

    char_to_token = {}

    

    current_pos = 0

    for token_idx, token in enumerate(tokens):

        token_start = text.find(token, current_pos)

        if token_start != -1:

            for char_pos in range(token_start, token_start + len(token)):

                char_to_token[char_pos] = token_idx

            current_pos = token_start + len(token)

    

    # Assign BIO labels based on entity spans

    for entity in entities:

        start_token = char_to_token.get(entity['start'])

        end_token = char_to_token.get(entity['end'] - 1)

        

        if start_token is not None and end_token is not None:

            bio_labels[start_token] = 'B-' + entity['label']

            for token_idx in range(start_token + 1, end_token + 1):

                bio_labels[token_idx] = 'I-' + entity['label']

    

    return bio_labels


This function demonstrates the complexity of aligning character-level entity annotations with token-level labels, which is a common preprocessing step in NER systems.


EVALUATION METRICS FOR NER SYSTEMS

Understanding how to evaluate NER systems is crucial for measuring progress and comparing different approaches. The evaluation of NER is more complex than simple classification tasks because we must consider both entity boundaries and entity types.


The most common evaluation metrics for NER are precision, recall, and F1 score, calculated at the entity level rather than the token level. Precision measures what proportion of entities predicted by the system are correct. Recall measures what proportion of actual entities in the text were found by the system. The F1 score is the harmonic mean of precision and recall, providing a single metric that balances both.


An entity prediction is considered correct only if both its boundaries and its type match the gold standard annotation exactly. This is called exact match evaluation. For example, if the gold standard has "New York City" labeled as a location, but the system predicts only "New York" as a location, this counts as both a false positive (for the incorrect "New York" prediction) and a false negative (for missing "New York City").

Here is how we might implement basic evaluation metrics:


def evaluate_ner(predicted_entities, gold_entities):

    # Convert entity lists to sets of tuples for comparison

    pred_set = set((e['start'], e['end'], e['label']) for e in predicted_entities)

    gold_set = set((e['start'], e['end'], e['label']) for e in gold_entities)

    

    # Calculate true positives, false positives, and false negatives

    true_positives = len(pred_set & gold_set)

    false_positives = len(pred_set - gold_set)

    false_negatives = len(gold_set - pred_set)

    

    # Calculate precision, recall, and F1

    precision = true_positives / (true_positives + false_positives) if (true_positives + false_positives) > 0 else 0

    recall = true_positives / (true_positives + false_negatives) if (true_positives + false_negatives) > 0 else 0

    f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0

    

    return {

        'precision': precision,

        'recall': recall,

        'f1': f1,

        'true_positives': true_positives,

        'false_positives': false_positives,

        'false_negatives': false_negatives

    }


Some evaluation frameworks also consider partial matches, where credit is given for partially correct predictions. For instance, if the system predicts "New York" when the correct entity is "New York City," a partial credit might be awarded. However, exact match remains the standard in most NER benchmarks.


MODERN DEEP LEARNING APPROACHES TO NER

The advent of deep learning has revolutionized NER, enabling systems to achieve unprecedented accuracy by automatically learning complex patterns from data. Understanding these modern approaches provides insight into the current state of the art.


The foundation of modern NER systems is word embeddings, which represent words as dense vectors in a continuous space. Unlike traditional one-hot encodings where each word is represented as a sparse vector with a single 1 and many 0s, embeddings capture semantic relationships between words. Words with similar meanings have similar vector representations.


Building on embeddings, recurrent neural networks, particularly Long Short-Term Memory networks, became popular for NER. LSTMs can process sequences of text and maintain memory of previous tokens, allowing them to capture context. A bidirectional LSTM processes the text in both forward and backward directions, giving the model access to both past and future context when making predictions for each token.


The architecture typically consists of an embedding layer that converts tokens to vectors, a bidirectional LSTM layer that processes these vectors while capturing context, and an output layer that predicts BIO labels for each token. Here is a conceptual outline of such a model:


import numpy as np


class BiLSTM_NER:

    def __init__(self, vocab_size, embedding_dim, hidden_dim, num_labels):

        self.vocab_size = vocab_size

        self.embedding_dim = embedding_dim

        self.hidden_dim = hidden_dim

        self.num_labels = num_labels

        

        # Initialize embeddings (in practice, these would be learned or pre-trained)

        self.embeddings = np.random.randn(vocab_size, embedding_dim) * 0.01

        

        # LSTM parameters would be initialized here

        # Output layer parameters would be initialized here

    

    def forward(self, token_ids):

        # Convert token IDs to embeddings

        embedded = self.embeddings[token_ids]

        

        # Process through bidirectional LSTM (simplified)

        # In practice, this would involve complex LSTM computations

        

        # Generate predictions for each token

        # predictions = output_layer(lstm_output)

        

        return None  # Placeholder for actual predictionsi


The most significant recent advancement is the use of transformer-based models, particularly BERT and its variants. BERT uses self-attention mechanisms to process text, allowing it to capture complex relationships between words regardless of their distance in the sequence. BERT is pre-trained on massive amounts of text using unsupervised learning, then fine-tuned on specific NER datasets.


The power of BERT for NER comes from its contextualized embeddings. Unlike traditional word embeddings where "bank" always has the same vector representation, BERT generates different embeddings for "bank" depending on whether it appears in "river bank" or "savings bank." This context-sensitivity is crucial for resolving ambiguities in NER.


PRACTICAL CONSIDERATIONS FOR DEPLOYING NER SYSTEMS

Moving from research to production requires addressing several practical considerations that affect how NER systems are deployed and maintained in real-world applications.


The first consideration is inference speed. While large transformer models achieve excellent accuracy, they can be slow to process text, especially on CPU hardware. For applications requiring real-time processing of large volumes of text, model optimization techniques like quantization, pruning, or knowledge distillation might be necessary to reduce model size and increase speed without significantly sacrificing accuracy.


The second consideration involves handling out-of-vocabulary words. No matter how large the training data, new words will appear in production that the model has never seen. Subword tokenization techniques like Byte Pair Encoding or WordPiece help address this by breaking unknown words into known subword units. For example, "COVID-19" might be split into "CO", "VID", "-", "19" if it was not in the training vocabulary.


The third consideration is model updating and maintenance. As language evolves and new entities emerge, NER models need to be updated. This requires establishing pipelines for collecting new training data, retraining models, and deploying updates. Active learning techniques can help identify the most informative examples to annotate, reducing the manual effort required.


The fourth consideration concerns error analysis and monitoring. In production, it is essential to track model performance and identify common error patterns. This might involve logging predictions, sampling outputs for manual review, and analyzing cases where the model has low confidence. Such analysis guides improvements to the model or training data.


Here is an example of how we might implement confidence-based filtering in a production system:


def filter_low_confidence_predictions(entities, confidence_threshold=0.7):

    high_confidence_entities = []

    low_confidence_entities = []

    

    for entity in entities:

        if entity.get('confidence', 0) >= confidence_threshold:

            high_confidence_entities.append(entity)

        else:

            low_confidence_entities.append(entity)

    

    return high_confidence_entities, low_confidence_entities


def process_with_human_review(text, ner_model, confidence_threshold=0.7):

    # Get predictions from the model

    predictions = ner_model.predict(text)

    

    # Separate high and low confidence predictions

    high_conf, low_conf = filter_low_confidence_predictions(predictions, confidence_threshold)

    

    # High confidence predictions can be used directly

    # Low confidence predictions are flagged for human review

    

    return {

        'automatic': high_conf,

        'needs_review': low_conf

    }


This approach allows the system to automatically process high-confidence predictions while routing uncertain cases to human reviewers, balancing automation with accuracy.


DOMAIN-SPECIFIC NER AND CUSTOMIZATION

While general-purpose NER systems work well for common entity types like persons, organizations, and locations, many applications require recognizing domain-specific entities. 


Understanding how to customize NER for specific domains is essential for practical applications.

In the biomedical domain, for instance, entities of interest include diseases, genes, proteins, drugs, and medical procedures. A sentence like "The patient was treated with aspirin for myocardial infarction" contains drug and disease entities that a general NER system would not recognize. Training a biomedical NER system requires annotated medical text and often benefits from domain-specific features like medical terminology databases.


In the financial domain, entities might include stock symbols, financial instruments, market indices, and regulatory terms. The sentence "The S&P 500 rose 2% after the Fed announced quantitative easing" contains financial entities that require domain knowledge to identify correctly.


Customizing NER for a specific domain typically involves several steps. First, define the entity types relevant to your domain. Second, create annotation guidelines that clearly specify what should be tagged and how to handle edge cases. Third, annotate a sufficient amount of domain-specific text, typically thousands of examples. Fourth, either train a model from scratch on this data or fine-tune a pre-trained model.


Here is a conceptual example of how domain-specific entity types might be defined:


# General domain entity types

GENERAL_ENTITY_TYPES = ['PERSON', 'ORGANIZATION', 'LOCATION', 'DATE', 'TIME', 'MONEY']


# Biomedical domain entity types

BIOMEDICAL_ENTITY_TYPES = ['DISEASE', 'GENE', 'PROTEIN', 'DRUG', 'SYMPTOM', 'TREATMENT']


# Financial domain entity types

FINANCIAL_ENTITY_TYPES = ['STOCK', 'CURRENCY', 'FINANCIAL_INSTRUMENT', 'MARKET_INDEX', 'COMPANY']


# E-commerce domain entity types

ECOMMERCE_ENTITY_TYPES = ['PRODUCT', 'BRAND', 'MODEL', 'FEATURE', 'PRICE']


def get_entity_types(domain):

    domain_types = {

        'general': GENERAL_ENTITY_TYPES,

        'biomedical': BIOMEDICAL_ENTITY_TYPES,

        'financial': FINANCIAL_ENTITY_TYPES,

        'ecommerce': ECOMMERCE_ENTITY_TYPES

    }

    return domain_types.get(domain, GENERAL_ENTITY_TYPES)


The choice of entity types should be driven by the downstream application. If you are building a medical information extraction system, you need entity types that support your specific use case, whether that is clinical trial matching, adverse event detection, or medical literature summarization.


HANDLING MULTILINGUAL NER

As applications become global, the need for NER systems that work across multiple languages has grown. Multilingual NER presents unique challenges beyond those of monolingual systems.


Different languages have different linguistic properties that affect NER. Some languages like German create compound words by concatenating multiple words together, making entity boundary detection more complex. Some languages like Chinese and Japanese do not use spaces between words, requiring different tokenization strategies. Some languages have rich morphology where words change form based on grammatical case, affecting how entities appear in text.


One approach to multilingual NER is to train separate models for each language. This requires annotated training data in each language and can be resource-intensive. Another approach is to use multilingual models like mBERT or XLM-RoBERTa, which are pre-trained on text from many languages simultaneously. These models can transfer knowledge across languages, allowing them to perform reasonably well even on languages with limited training data.


Cross-lingual transfer is particularly valuable for low-resource languages where annotated NER data is scarce. A model trained on English NER data can be adapted to recognize entities in a related language with minimal additional training data. This is enabled by the shared multilingual representation space learned by models like mBERT.


Here is a conceptual example of how language-specific processing might be handled:


def preprocess_text(text, language):

    if language == 'chinese' or language == 'japanese':

        # Use character-based or subword tokenization

        tokens = character_tokenize(text)

    elif language == 'german':

        # Handle compound words

        tokens = compound_aware_tokenize(text)

    else:

        # Standard whitespace-based tokenization

        tokens = simple_tokenize(text)

    

    return tokens


def character_tokenize(text):

    # For languages without word boundaries

    return list(text)


def compound_aware_tokenize(text):

    # Placeholder for German compound handling

    return simple_tokenize(text)


The challenge in multilingual NER is maintaining consistent entity definitions across languages while respecting language-specific conventions. What constitutes a "person name" might differ between cultures, and annotation guidelines must account for these differences.


ADVANCED TOPICS: NESTED ENTITIES AND ENTITY LINKING

Beyond basic NER, several advanced topics extend the capabilities of entity recognition systems. Two particularly important topics are nested entities and entity linking.


Nested entities occur when one entity contains another entity within it. For example, in the phrase "University of Southern California," the entire phrase is an organization entity, but "Southern California" is also a location entity. Traditional BIO tagging schemes cannot represent such nested structures, as each token can only have one label.


Handling nested entities requires different approaches. One method is to use multiple passes, where the system first identifies outer entities, then identifies inner entities within them. Another method uses more sophisticated labeling schemes that can represent hierarchical structures. A third approach treats NER as a span classification problem rather than a token classification problem, directly predicting entity spans at different levels.


Entity linking, also called entity disambiguation or entity resolution, goes beyond recognizing that an entity exists to determining which real-world entity it refers to. For example, recognizing "Paris" as a location is NER, but determining whether it refers to Paris in France, Paris in Texas, or Paris Hilton is entity linking.


Entity linking typically involves two steps. First, generate candidate entities from a knowledge base that the mention could refer to. Second, rank these candidates based on context to select the most likely one. Features used for ranking might include string similarity between the mention and candidate names, the popularity of the candidate entity, and the semantic coherence between the candidate and the surrounding context.


Here is a simplified example of entity linking logic:


def link_entity(mention, context, knowledge_base):

    # Generate candidate entities

    candidates = knowledge_base.get_candidates(mention)

    

    if not candidates:

        return None

    

    # Score each candidate based on context

    scored_candidates = []

    for candidate in candidates:

        score = calculate_linking_score(mention, candidate, context)

        scored_candidates.append((candidate, score))

    

    # Return the highest-scoring candidate

    scored_candidates.sort(key=lambda x: x[1], reverse=True)

    return scored_candidates[0][0]


def calculate_linking_score(mention, candidate, context):

    # String similarity between mention and candidate names

    string_sim = string_similarity(mention, candidate['name'])

    

    # Popularity of the candidate entity

    popularity = candidate.get('popularity', 0)

    

    # Semantic similarity between context and candidate description

    context_sim = semantic_similarity(context, candidate.get('description', ''))

    

    # Combine scores (weights would be learned from data)

    score = 0.4 * string_sim + 0.3 * popularity + 0.3 * context_sim

    return score


def string_similarity(str1, str2):

    # Placeholder for string similarity calculation

    return 0.5


def semantic_similarity(text1, text2):

    # Placeholder for semantic similarity calculation

    return 0.5


Entity linking enables applications to connect unstructured text to structured knowledge bases, supporting tasks like question answering and knowledge graph construction.


FULL RUNNING EXAMPLE: PRODUCTION-READY NER SYSTEM

Now we present a complete, production-ready implementation of an NER system that incorporates the concepts discussed throughout this tutorial. This implementation uses a machine learning approach with feature-based classification and includes all necessary components for real-world use.


import re

import json

from collections import defaultdict, Counter

from typing import List, Dict, Tuple, Set

import pickle



class Token:

    """Represents a single token with its properties."""

    

    def __init__(self, text: str, start: int, end: int):

        self.text = text

        self.start = start

        self.end = end

        self.features = {}

        self.label = 'O'

    

    def __repr__(self):

        return f"Token('{self.text}', {self.start}, {self.end}, '{self.label}')"



class Tokenizer:

    """Handles text tokenization with position tracking."""

    

    def tokenize(self, text: str) -> List[Token]:

        """Tokenize text while preserving character positions."""

        tokens = []

        pattern = r'\w+|[^\w\s]'

        

        for match in re.finditer(pattern, text):

            token = Token(match.group(), match.start(), match.end())

            tokens.append(token)

        

        return tokens



class FeatureExtractor:

    """Extracts features from tokens for classification."""

    

    def __init__(self):

        self.word_shape_cache = {}

    

    def extract_features(self, tokens: List[Token], index: int) -> Dict:

        """Extract comprehensive features for a token at given index."""

        token = tokens[index]

        features = {}

        

        # Token-level features

        features['token_lower'] = token.text.lower()

        features['token_length'] = len(token.text)

        features['is_capitalized'] = token.text[0].isupper() if token.text else False

        features['is_all_caps'] = token.text.isupper()

        features['is_all_lower'] = token.text.islower()

        features['is_title'] = token.text.istitle()

        features['is_numeric'] = token.text.isdigit()

        features['is_alphanumeric'] = token.text.isalnum()

        features['contains_digit'] = any(c.isdigit() for c in token.text)

        features['contains_hyphen'] = '-' in token.text

        features['contains_apostrophe'] = "'" in token.text

        

        # Word shape features

        features['word_shape'] = self._get_word_shape(token.text)

        features['short_word_shape'] = self._get_short_word_shape(token.text)

        

        # Prefix and suffix features

        for n in range(1, min(5, len(token.text) + 1)):

            features[f'prefix_{n}'] = token.text[:n].lower()

            features[f'suffix_{n}'] = token.text[-n:].lower()

        

        # Position features

        features['is_first'] = index == 0

        features['is_last'] = index == len(tokens) - 1

        features['position_ratio'] = index / len(tokens) if len(tokens) > 0 else 0

        

        # Context features - previous tokens

        for offset in range(1, 4):

            if index >= offset:

                prev_token = tokens[index - offset]

                features[f'prev_{offset}_token'] = prev_token.text.lower()

                features[f'prev_{offset}_capitalized'] = prev_token.text[0].isupper() if prev_token.text else False

                features[f'prev_{offset}_shape'] = self._get_word_shape(prev_token.text)

            else:

                features[f'prev_{offset}_token'] = '<START>'

                features[f'prev_{offset}_capitalized'] = False

                features[f'prev_{offset}_shape'] = '<START>'

        

        # Context features - next tokens

        for offset in range(1, 4):

            if index + offset < len(tokens):

                next_token = tokens[index + offset]

                features[f'next_{offset}_token'] = next_token.text.lower()

                features[f'next_{offset}_capitalized'] = next_token.text[0].isupper() if next_token.text else False

                features[f'next_{offset}_shape'] = self._get_word_shape(next_token.text)

            else:

                features[f'next_{offset}_token'] = '<END>'

                features[f'next_{offset}_capitalized'] = False

                features[f'next_{offset}_shape'] = '<END>'

        

        return features

    

    def _get_word_shape(self, word: str) -> str:

        """Get detailed word shape (e.g., 'Apple' -> 'Xxxxx')."""

        if word in self.word_shape_cache:

            return self.word_shape_cache[word]

        

        shape = []

        for char in word:

            if char.isupper():

                shape.append('X')

            elif char.islower():

                shape.append('x')

            elif char.isdigit():

                shape.append('d')

            else:

                shape.append(char)

        

        result = ''.join(shape)

        self.word_shape_cache[word] = result

        return result

    

    def _get_short_word_shape(self, word: str) -> str:

        """Get compressed word shape (e.g., 'Apple' -> 'Xx')."""

        shape = self._get_word_shape(word)

        if not shape:

            return ''

        

        compressed = [shape[0]]

        for char in shape[1:]:

            if char != compressed[-1]:

                compressed.append(char)

        

        return ''.join(compressed)



class GazetteerMatcher:

    """Matches tokens against gazetteer lists."""

    

    def __init__(self):

        self.gazetteers = {

            'PERSON': set(),

            'ORGANIZATION': set(),

            'LOCATION': set(),

            'TITLE': set()

        }

    

    def add_entries(self, entity_type: str, entries: List[str]):

        """Add entries to a gazetteer."""

        if entity_type in self.gazetteers:

            self.gazetteers[entity_type].update(entry.lower() for entry in entries)

    

    def match(self, text: str) -> Set[str]:

        """Find which gazetteers match the given text."""

        text_lower = text.lower()

        matches = set()

        

        for entity_type, entries in self.gazetteers.items():

            if text_lower in entries:

                matches.add(entity_type)

        

        return matches

    

    def load_default_gazetteers(self):

        """Load default gazetteer entries."""

        # Common person titles

        self.add_entries('TITLE', ['Mr', 'Mrs', 'Ms', 'Dr', 'Prof', 'Sir', 'Lady'])

        

        # Common organization suffixes

        self.add_entries('ORGANIZATION', ['Inc', 'Corp', 'Ltd', 'LLC', 'Company', 'Corporation'])

        

        # Sample person names

        self.add_entries('PERSON', ['John', 'Mary', 'Steve', 'Sarah', 'Michael', 'Jennifer'])

        

        # Sample locations

        self.add_entries('LOCATION', ['California', 'Texas', 'London', 'Paris', 'Tokyo', 'New York'])



class SimpleClassifier:

    """Simple statistical classifier for NER."""

    

    def __init__(self):

        self.label_counts = defaultdict(int)

        self.feature_label_counts = defaultdict(lambda: defaultdict(int))

        self.feature_counts = defaultdict(int)

        self.labels = set()

    

    def train(self, training_data: List[Tuple[Dict, str]]):

        """Train the classifier on feature-label pairs."""

        for features, label in training_data:

            self.labels.add(label)

            self.label_counts[label] += 1

            

            for feature_name, feature_value in features.items():

                feature_key = f"{feature_name}={feature_value}"

                self.feature_label_counts[feature_key][label] += 1

                self.feature_counts[feature_key] += 1

    

    def predict(self, features: Dict) -> Tuple[str, float]:

        """Predict label for given features with confidence score."""

        if not self.labels:

            return 'O', 0.0

        

        label_scores = defaultdict(float)

        total_count = sum(self.label_counts.values())

        

        # Calculate scores for each label

        for label in self.labels:

            # Prior probability

            score = self.label_counts[label] / total_count

            

            # Feature probabilities

            for feature_name, feature_value in features.items():

                feature_key = f"{feature_name}={feature_value}"

                

                if feature_key in self.feature_label_counts:

                    feature_given_label = self.feature_label_counts[feature_key][label]

                    label_total = self.label_counts[label]

                    

                    # Laplace smoothing

                    probability = (feature_given_label + 1) / (label_total + len(self.labels))

                    score *= probability

            

            label_scores[label] = score

        

        # Normalize scores

        total_score = sum(label_scores.values())

        if total_score > 0:

            for label in label_scores:

                label_scores[label] /= total_score

        

        # Return label with highest score

        if label_scores:

            best_label = max(label_scores.items(), key=lambda x: x[1])

            return best_label[0], best_label[1]

        

        return 'O', 0.0



class Entity:

    """Represents a recognized named entity."""

    

    def __init__(self, text: str, label: str, start: int, end: int, confidence: float = 1.0):

        self.text = text

        self.label = label

        self.start = start

        self.end = end

        self.confidence = confidence

    

    def to_dict(self) -> Dict:

        """Convert entity to dictionary representation."""

        return {

            'text': self.text,

            'label': self.label,

            'start': self.start,

            'end': self.end,

            'confidence': self.confidence

        }

    

    def __repr__(self):

        return f"Entity('{self.text}', {self.label}, {self.start}-{self.end}, conf={self.confidence:.2f})"



class NERSystem:

    """Complete Named Entity Recognition system."""

    

    def __init__(self):

        self.tokenizer = Tokenizer()

        self.feature_extractor = FeatureExtractor()

        self.gazetteer = GazetteerMatcher()

        self.classifier = SimpleClassifier()

        self.entity_types = ['PERSON', 'ORGANIZATION', 'LOCATION', 'DATE', 'TIME', 'MONEY']

        self.trained = False

    

    def prepare_training_data(self, annotated_examples: List[Dict]) -> List[Tuple[Dict, str]]:

        """Convert annotated examples to training format."""

        training_data = []

        

        for example in annotated_examples:

            text = example['text']

            entities = example['entities']

            

            # Tokenize

            tokens = self.tokenizer.tokenize(text)

            

            # Assign BIO labels

            labels = self._assign_bio_labels(tokens, entities)

            

            # Extract features for each token

            for i, token in enumerate(tokens):

                features = self.feature_extractor.extract_features(tokens, i)

                

                # Add gazetteer features

                gazetteer_matches = self.gazetteer.match(token.text)

                for entity_type in self.entity_types:

                    features[f'in_gazetteer_{entity_type}'] = entity_type in gazetteer_matches

                

                training_data.append((features, labels[i]))

        

        return training_data

    

    def _assign_bio_labels(self, tokens: List[Token], entities: List[Dict]) -> List[str]:

        """Assign BIO labels to tokens based on entity annotations."""

        labels = ['O'] * len(tokens)

        

        # Create character to token index mapping

        char_to_token = {}

        for i, token in enumerate(tokens):

            for pos in range(token.start, token.end):

                char_to_token[pos] = i

        

        # Assign labels based on entities

        for entity in entities:

            start_token_idx = char_to_token.get(entity['start'])

            end_token_idx = char_to_token.get(entity['end'] - 1)

            

            if start_token_idx is not None and end_token_idx is not None:

                labels[start_token_idx] = f"B-{entity['label']}"

                

                for idx in range(start_token_idx + 1, end_token_idx + 1):

                    labels[idx] = f"I-{entity['label']}"

        

        return labels

    

    def train(self, annotated_examples: List[Dict]):

        """Train the NER system on annotated data."""

        # Load default gazetteers

        self.gazetteer.load_default_gazetteers()

        

        # Prepare training data

        training_data = self.prepare_training_data(annotated_examples)

        

        # Train classifier

        self.classifier.train(training_data)

        

        self.trained = True

    

    def predict(self, text: str, confidence_threshold: float = 0.5) -> List[Entity]:

        """Predict entities in the given text."""

        if not self.trained:

            raise ValueError("Model must be trained before prediction")

        

        # Tokenize

        tokens = self.tokenizer.tokenize(text)

        

        # Predict labels for each token

        predictions = []

        confidences = []

        

        for i, token in enumerate(tokens):

            features = self.feature_extractor.extract_features(tokens, i)

            

            # Add gazetteer features

            gazetteer_matches = self.gazetteer.match(token.text)

            for entity_type in self.entity_types:

                features[f'in_gazetteer_{entity_type}'] = entity_type in gazetteer_matches

            

            label, confidence = self.classifier.predict(features)

            predictions.append(label)

            confidences.append(confidence)

        

        # Convert BIO labels to entities

        entities = self._bio_to_entities(tokens, predictions, confidences, confidence_threshold)

        

        return entities

    

    def _bio_to_entities(self, tokens: List[Token], labels: List[str], 

                        confidences: List[float], threshold: float) -> List[Entity]:

        """Convert BIO labels to Entity objects."""

        entities = []

        current_entity = None

        current_tokens = []

        current_confidences = []

        

        for i, (token, label, confidence) in enumerate(zip(tokens, labels, confidences)):

            if label.startswith('B-'):

                # Save previous entity if exists

                if current_entity:

                    entities.append(self._create_entity(current_tokens, current_entity, 

                                                      current_confidences, threshold))

                

                # Start new entity

                current_entity = label[2:]

                current_tokens = [token]

                current_confidences = [confidence]

            

            elif label.startswith('I-') and current_entity == label[2:]:

                # Continue current entity

                current_tokens.append(token)

                current_confidences.append(confidence)

            

            else:

                # End current entity

                if current_entity:

                    entities.append(self._create_entity(current_tokens, current_entity, 

                                                      current_confidences, threshold))

                    current_entity = None

                    current_tokens = []

                    current_confidences = []

        

        # Handle last entity

        if current_entity:

            entities.append(self._create_entity(current_tokens, current_entity, 

                                              current_confidences, threshold))

        

        return entities

    

    def _create_entity(self, tokens: List[Token], label: str, 

                      confidences: List[float], threshold: float) -> Entity:

        """Create an Entity object from tokens."""

        if not tokens:

            return None

        

        text = ' '.join(token.text for token in tokens)

        start = tokens[0].start

        end = tokens[-1].end

        avg_confidence = sum(confidences) / len(confidences) if confidences else 0.0

        

        if avg_confidence >= threshold:

            return Entity(text, label, start, end, avg_confidence)

        

        return None

    

    def evaluate(self, test_examples: List[Dict]) -> Dict:

        """Evaluate the NER system on test data."""

        all_predictions = []

        all_gold = []

        

        for example in test_examples:

            predicted = self.predict(example['text'])

            gold = example['entities']

            

            all_predictions.extend(predicted)

            all_gold.extend(gold)

        

        return self._calculate_metrics(all_predictions, all_gold)

    

    def _calculate_metrics(self, predicted: List[Entity], gold: List[Dict]) -> Dict:

        """Calculate precision, recall, and F1 score."""

        # Convert to comparable format

        pred_set = set()

        for entity in predicted:

            if entity:

                pred_set.add((entity.start, entity.end, entity.label))

        

        gold_set = set()

        for entity in gold:

            gold_set.add((entity['start'], entity['end'], entity['label']))

        

        # Calculate metrics

        true_positives = len(pred_set & gold_set)

        false_positives = len(pred_set - gold_set)

        false_negatives = len(gold_set - pred_set)

        

        precision = true_positives / (true_positives + false_positives) if (true_positives + false_positives) > 0 else 0

        recall = true_positives / (true_positives + false_negatives) if (true_positives + false_negatives) > 0 else 0

        f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0

        

        return {

            'precision': precision,

            'recall': recall,

            'f1': f1,

            'true_positives': true_positives,

            'false_positives': false_positives,

            'false_negatives': false_negatives

        }

    

    def save(self, filepath: str):

        """Save the trained model to disk."""

        model_data = {

            'classifier': self.classifier,

            'gazetteer': self.gazetteer,

            'entity_types': self.entity_types,

            'trained': self.trained

        }

        

        with open(filepath, 'wb') as f:

            pickle.dump(model_data, f)

    

    def load(self, filepath: str):

        """Load a trained model from disk."""

        with open(filepath, 'rb') as f:

            model_data = pickle.load(f)

        

        self.classifier = model_data['classifier']

        self.gazetteer = model_data['gazetteer']

        self.entity_types = model_data['entity_types']

        self.trained = model_data['trained']



def create_sample_training_data() -> List[Dict]:

    """Create comprehensive sample training data."""

    return [

        {

            'text': 'Apple Inc. was founded by Steve Jobs in Cupertino, California.',

            'entities': [

                {'start': 0, 'end': 10, 'label': 'ORGANIZATION', 'text': 'Apple Inc.'},

                {'start': 26, 'end': 36, 'label': 'PERSON', 'text': 'Steve Jobs'},

                {'start': 40, 'end': 49, 'label': 'LOCATION', 'text': 'Cupertino'},

                {'start': 51, 'end': 61, 'label': 'LOCATION', 'text': 'California'}

            ]

        },

        {

            'text': 'Microsoft Corporation announced a new product on Monday.',

            'entities': [

                {'start': 0, 'end': 21, 'label': 'ORGANIZATION', 'text': 'Microsoft Corporation'},

                {'start': 49, 'end': 55, 'label': 'DATE', 'text': 'Monday'}

            ]

        },

        {

            'text': 'Dr. Sarah Johnson works at Stanford University in Palo Alto.',

            'entities': [

                {'start': 4, 'end': 17, 'label': 'PERSON', 'text': 'Sarah Johnson'},

                {'start': 27, 'end': 46, 'label': 'ORGANIZATION', 'text': 'Stanford University'},

                {'start': 50, 'end': 59, 'label': 'LOCATION', 'text': 'Palo Alto'}

            ]

        },

        {

            'text': 'The meeting is scheduled for 3:00 PM on January 15, 2024.',

            'entities': [

                {'start': 29, 'end': 36, 'label': 'TIME', 'text': '3:00 PM'},

                {'start': 40, 'end': 56, 'label': 'DATE', 'text': 'January 15, 2024'}

            ]

        },

        {

            'text': 'Google LLC acquired the startup for $500 million.',

            'entities': [

                {'start': 0, 'end': 10, 'label': 'ORGANIZATION', 'text': 'Google LLC'},

                {'start': 36, 'end': 48, 'label': 'MONEY', 'text': '$500 million'}

            ]

        },

        {

            'text': 'Prof. Michael Chen published research in Nature journal.',

            'entities': [

                {'start': 6, 'end': 19, 'label': 'PERSON', 'text': 'Michael Chen'},

                {'start': 41, 'end': 47, 'label': 'ORGANIZATION', 'text': 'Nature'}

            ]

        },

        {

            'text': 'Amazon opened a new warehouse in Seattle, Washington.',

            'entities': [

                {'start': 0, 'end': 6, 'label': 'ORGANIZATION', 'text': 'Amazon'},

                {'start': 33, 'end': 40, 'label': 'LOCATION', 'text': 'Seattle'},

                {'start': 42, 'end': 52, 'label': 'LOCATION', 'text': 'Washington'}

            ]

        },

        {

            'text': 'The conference will be held in London from June 1 to June 5.',

            'entities': [

                {'start': 31, 'end': 37, 'label': 'LOCATION', 'text': 'London'},

                {'start': 43, 'end': 49, 'label': 'DATE', 'text': 'June 1'},

                {'start': 53, 'end': 59, 'label': 'DATE', 'text': 'June 5'}

            ]

        },

        {

            'text': 'IBM Corporation has offices in New York City and Tokyo.',

            'entities': [

                {'start': 0, 'end': 15, 'label': 'ORGANIZATION', 'text': 'IBM Corporation'},

                {'start': 31, 'end': 44, 'label': 'LOCATION', 'text': 'New York City'},

                {'start': 49, 'end': 54, 'label': 'LOCATION', 'text': 'Tokyo'}

            ]

        },

        {

            'text': 'Jennifer Smith joined Tesla Inc. as CEO in March 2023.',

            'entities': [

                {'start': 0, 'end': 14, 'label': 'PERSON', 'text': 'Jennifer Smith'},

                {'start': 22, 'end': 32, 'label': 'ORGANIZATION', 'text': 'Tesla Inc.'},

                {'start': 44, 'end': 54, 'label': 'DATE', 'text': 'March 2023'}

            ]

        }

    ]



def main():

    """Main function demonstrating the NER system."""

    print("=" * 80)

    print("NAMED ENTITY RECOGNITION SYSTEM - DEMONSTRATION")

    print("=" * 80)

    print()

    

    # Create NER system

    print("Initializing NER system...")

    ner = NERSystem()

    

    # Create training data

    print("Creating training data...")

    training_data = create_sample_training_data()

    print(f"Created {len(training_data)} training examples")

    print()

    

    # Train the system

    print("Training the NER system...")

    ner.train(training_data)

    print("Training completed!")

    print()

    

    # Test on new examples

    test_texts = [

        "Microsoft announced a partnership with OpenAI in San Francisco.",

        "Dr. John Williams will speak at the conference in Paris on Friday.",

        "The stock price increased by $50 on Tuesday morning.",

        "Apple Inc. released a new iPhone model in September 2024.",

        "Prof. Mary Johnson works at MIT in Cambridge, Massachusetts."

    ]

    

    print("Testing on new examples:")

    print("-" * 80)

    

    for i, text in enumerate(test_texts, 1):

        print(f"\nExample {i}: {text}")

        print()

        

        entities = ner.predict(text, confidence_threshold=0.3)

        

        if entities:

            print("Detected entities:")

            for entity in entities:

                if entity:

                    print(f"  - {entity.text:30s} [{entity.label:15s}] (confidence: {entity.confidence:.2f})")

        else:

            print("  No entities detected")

    

    print()

    print("-" * 80)

    

    # Demonstrate evaluation

    print("\nEvaluating on training data (for demonstration):")

    metrics = ner.evaluate(training_data[:5])

    print(f"Precision: {metrics['precision']:.3f}")

    print(f"Recall:    {metrics['recall']:.3f}")

    print(f"F1 Score:  {metrics['f1']:.3f}")

    print()

    

    # Demonstrate saving and loading

    print("Saving model to disk...")

    ner.save('ner_model.pkl')

    print("Model saved successfully!")

    print()

    

    print("Loading model from disk...")

    ner_loaded = NERSystem()

    ner_loaded.load('ner_model.pkl')

    print("Model loaded successfully!")

    print()

    

    # Test loaded model

    test_text = "Google LLC is headquartered in Mountain View, California."

    print(f"Testing loaded model on: {test_text}")

    entities = ner_loaded.predict(test_text, confidence_threshold=0.3)

    

    if entities:

        print("Detected entities:")

        for entity in entities:

            if entity:

                print(f"  - {entity.text:30s} [{entity.label:15s}] (confidence: {entity.confidence:.2f})")

    

    print()

    print("=" * 80)

    print("DEMONSTRATION COMPLETED")

    print("=" * 80)



if __name__ == "__main__":

    main()