Natural Language Processing · Quora Question Pairs · PyTorch + MLflow

Quora Question Pair Duplicate Detection Research Lab

A deep NLP research project around semantic duplicate-question detection, built as an extensible research lab rather than a one-off notebook. The current completed track focuses on LSTM attention, including my own attention implementation, broad MLflow experiment tracking, calibrated F1 optimization, and reusable framework-style engineering for future research expansion.
Research Lab Kaggle Competition
Training Rows
0
Duplicate Share
0
Best Calibrated F1
0
Validation AUROC
0
Average Precision
0
Optimal Threshold
0
Project Thesis

From Duplicate Questions to a Reusable NLP Lab

The project solves Quora duplicate-question detection while also building a cleaner research workflow around experiments, artifacts, and reusable code.

The task is binary semantic matching: given question1 and question2, predict whether both questions ask the same thing. The dataset contains 404,290 labeled pairs, with 149,263 duplicate pairs and 255,027 non-duplicate pairs. The current completed research track is an LSTM-attention approach: both questions pass through a shared PyTorch encoder with a custom attention module, then the pair is represented with h1, h2, |h1-h2|, h1*h2, and cosine similarity before classification.

This project also marks a jump in engineering style. The notebook is organized like a small Hugging Face-inspired ecosystem: configuration classes, tokenizer object, dataset object, MLflow tracker, trainer, requirement generator, exported model architecture script, checkpoints, vocabulary files, and JSON summaries. That structure makes the research easier to audit and easier to reuse later for deployment or additional modeling tracks.

Winner run IDc3f2f9597fb94be29d8068e86166a519. This is mentioned once to identify the champion experiment tracked during research.
Main resultBest calibrated validation F1 is 0.8131, with AUROC 0.9234 and Average Precision 0.8719.
Research scopeThe page covers the winner notebook, but the project explored many attention and loss variants through MLflow.
Model Libraries

Dependencies and Their Role in the Project

The page covers the third-party stack used in the winner notebook, with each library tied to its actual purpose.

PyTorch 2.8.0+cu126Core deep learning framework: embeddings, BiLSTM layers, custom attention modules, Siamese classifier, BCE loss, AdamW optimizer, schedulers, gradient clipping, checkpoint tensors, and CUDA execution.
TorchMetrics 1.8.2Training and validation metrics: BinaryAccuracy, BinaryPrecision, BinaryRecall, BinaryF1Score, AUROC, AveragePrecision, and PrecisionRecallCurve for threshold calibration.
MLflow 3.9.0Experiment tracking for run names, parameters, per-epoch metrics, best-model logging, reproducibility summaries, and comparison across LSTM-attention variants.
Pandas 2.3.3 & NumPy 2.4.1CSV ingestion, dataframe cleaning, null handling, class distribution checks, train/validation preparation, embedding matrix initialization, probability arrays, and diagnostic summaries.
Scikit-Learn 1.8.0 & SciPy 1.17.0Stratified train/validation split, precision-recall support, calibration analysis, PCA-style feature projection in error analysis, and scientific-computing support for sklearn workflows.
NLTK 3.9.4English stopword list used for tokenizer stop masks and token-frequency inspection.
Matplotlib 3.10.8Diagnostic visualizations: class/probability distributions, calibration curves, attention/error plots, and feature-space views.
TQDM 4.67.1 & IPython 9.5.0Progress bars for training/validation loops and notebook extension support for the custom requirements refresh magic.
Experiment Breadth

Architecture Search Before the Winner

The winner was selected after a wide set of LSTM-attention experiments, not from a single baseline attempt.

01
Single-head self attentionStarted from LSTM encoders with manually implemented attention pooling to understand how question tokens contribute to the final vector.
02
Multi-head self attentionMoved from one attention view to multiple attention heads, letting the model pool several subspaces of the LSTM sequence output.
03
Loss-function comparisonCompared contrastive-style distance objectives against BCE-with-logits classification, then kept the BCE formulation with label smoothing for the winner run.
04
Cross attention familiesTested multi-head cross attention, self attention plus cross attention, and ordering variants around CNN blocks.
05
CNN hybrid variantsExplored CNN plus multi-head cross attention and multi-head cross attention plus CNN to capture local phrase patterns as well as pair interactions.
06
ESIM-like directionPrototyped entailment-style matching ideas before settling on the best calibrated LSTM-attention classifier for this page.
Dataset Anatomy

Raw Fields and Modeling Target

The notebook keeps the problem clean: two questions in, binary duplicate probability out.

ColumnTypeRole
idIdentifierRow-level identifier from the Quora question-pair training data.
qid1, qid2Question IDsOriginal Quora question identifiers for both sides of the pair.
question1, question2Raw textThe two natural-language questions cleaned, tokenized, padded/truncated, embedded, and encoded by the Siamese model.
is_duplicateTargetBinary label: 1 means duplicate semantic intent, 0 means different intent.

Class balance

The label distribution is moderately imbalanced: 36.92% duplicate and 63.08% non-duplicate. The training code therefore optimizes threshold-sensitive metrics such as F1, precision, recall, AUROC, and Average Precision instead of relying only on accuracy.

Text normalization

QuoraPreproccesor fills missing questions, lowercases text, removes punctuation noise, normalizes spacing, and prepares both question columns before vocabulary building and dataset construction.

Champion Architecture

Siamese BiLSTM With Multi-Head Bahdanau Attention

The selected model uses shared question encoders, manually implemented multi-head additive attention, and engineered pair-comparison features.

Question 1
tokens, length 50
Shared Embedding
GloVe 6B, 100d
2-Layer BiLSTM
hidden 384 x 2
4-Head Bahdanau
masked pooling
Shared encoder weights are reused for Question 2
Question 2
tokens, length 50
Shared Embedding
frozen then unfrozen
2-Layer BiLSTM
dropout 0.35
4-Head Bahdanau
context vector
Pair Feature Vector
h1 + h2 + |h1-h2| + h1*h2 + cosine
MLP Classifier
3073 -> 1024 -> 256 -> 1 logit
Duplicate Probability
threshold 0.4843
Encoder outputBiLSTM output is 768 dimensional because hidden_dim=384 and the LSTM is bidirectional.
Attention designEach head projects sequence states into a head subspace, scores tokens with additive attention, masks padding, and pools a context vector.
Final feature sizeFour vector features of size 768 plus one cosine scalar produce an input dimension of 3073.
Winner Configuration

Model and Training Hyperparameters

These values are pulled from the winner run artifacts and config JSON.

Model
LSTM_attention / MultiHead-Bahdanau2-layer bidirectional LSTM, hidden_dim=384, num_heads=4, dropout 0.35, attention dropout 0.0.
EmbeddingGloVe 100d
LSTM Out768
FC Dims1024, 256
Tokens
Custom vocabulary and fixed-length encoding<PAD> index 0, <UNK> index 1, lowercase text, maximum length 50, vocabulary cap 20000.
Batch128
Pin Memorytrue
Workers0
Optimization
BCE with label smoothing and AdamWLabel smoothing 0.05, learning rate 0.0005, weight decay 0.001, gradient clipping 1.5.
SchedulerPlateau
Factor0.5
Min LR1e-7
Control
F1 checkpointing with loss-based early stoppingThe trainer checkpoints on F1Score in max mode, watches validation loss for early stopping, and recalibrates the threshold from the precision-recall curve.
Patience2
Epoch Cap50
Stopped9
Implementation Core

Custom Attention and Siamese Forward Pass

The main architecture is not a black-box layer; attention is implemented directly in PyTorch.

AttentionHead + MultiHeadAttention
class AttentionHead(nn.Module):
    def forward(self, lstm_output, mask):
        proj = self.W(lstm_output)
        energy = torch.tanh(proj)
        scores = self.V(energy).squeeze(-1)
        scores = scores.masked_fill(mask == 0, self.mask_fill_num)
        weights = F.softmax(scores, dim=-1)
        context = torch.bmm(weights.unsqueeze(1), proj * mask.unsqueeze(-1))
        return context.squeeze(1)

class MultiHeadAttention(nn.Module):
    def forward(self, hidden_state, mask):
        x = torch.cat([h(hidden_state, mask) for h in self.heads], dim=-1)
        return self.out_linear(x)
QuoraSiameseClassifier.forward
def forward(self, q1, q2):
    h1 = self._encode(q1)
    h2 = self._encode(q2)

    cosine_sim = F.cosine_similarity(h1, h2).unsqueeze(-1)
    feat = torch.cat([
        h1,
        h2,
        abs(h1 - h2),
        h1 * h2,
        cosine_sim
    ], dim=1)

    logits = self.fc_dims(feat)
    return logits.squeeze(-1)
Architecture Code

ModelConfig and Encoder Internals

The architecture is driven by explicit configuration, then assembled into a reusable Siamese encoder.

ModelConfig essentials
class ModelConfig:
    MODEL_TYPE = "LSTM_attention"
    ATTENTION_TYPE = "MultiHead-Bahdanau"
    TOKEN_EMBEDDING = "gloVe-6B-100d"
    EMB_DIM = 100
    FREEZE_TOKEN_EMBEDDING = True

    LOSS = "BCE with Logits"
    NUM_HEADS = 4
    BIDIRECTIONAL = True
    HIDDEN_DIM = 384
    LSTM_OUT = HIDDEN_DIM * 2
    NUM_LAYERS = 2
    DROPOUT = 0.35

    LABEL_SMOOTHING = 0.05
    FC_DIMS = [1024, 256]
    FC_DP = 0.4
    INPUT_FC_DIM = 3073
Question encoder path
def _encode(self, question):
    emb = self.embedding(question)
    emb = self.emb_dropout(emb)

    mask = self._create_mask(question)
    if self.stop_mask is not None:
        token_stop_mask = self.stop_mask[question]
        mask = mask * token_stop_mask.float()

    out, _ = self.LSTM(emb)
    ctx = self.attention(out, mask)
    return ctx

Attention concept in plain terms

Winner encoder

Each question becomes a sequence of LSTM hidden states. For each head, the model projects every token state, scores it, masks padding and stop tokens, normalizes scores with softmax, and produces a weighted context vector. Multi-head attention repeats that in parallel subspaces, concatenates the heads, and projects them back into one 768-dimensional question representation.

score_t = V tanh(W h_t),   alpha = softmax(mask(score)),   context = sum_t alpha_t projected(h_t)
Layer Construction

How QuoraSiameseClassifier Is Built

The constructor wires together pretrained embeddings, recurrent encoding, attention pooling, and a pair-classification head.

QuoraSiameseClassifier.__init__ sketch
class QuoraSiameseClassifier(nn.Module):
    def __init__(self, vocab_size, config, embedding=None, stop_mask=None):
        self.embedding = nn.Embedding(vocab_size, config.EMB_DIM)
        self.emb_dropout = nn.Dropout(config.EMB_DP)

        if embedding is not None:
            self.embedding.weight.data.copy_(embedding)
            self.embedding.weight.requires_grad = not config.FREEZE_TOKEN_EMBEDDING

        self.LSTM = nn.LSTM(
            input_size=config.EMB_DIM,
            hidden_size=config.HIDDEN_DIM,
            bidirectional=config.BIDIRECTIONAL,
            num_layers=config.NUM_LAYERS,
            dropout=config.DROPOUT,
            batch_first=True
        )
        self.attention = MultiHeadAttention(config.LSTM_OUT)
        self.proj = nn.Identity()
        self.fc_dims = self._build_fc_layers(config.INPUT_FC_DIM, config.FC_DIMS, config.FC_DP)
ComponentPurpose
nn.EmbeddingMaps token IDs to dense vectors initialized from GloVe when available.
nn.LSTMEncodes token order and contextual dependencies in each question independently.
MultiHeadAttentionCompresses sequence output into a context vector while respecting masks.
nn.IdentityKeeps projection optional; winner run uses full 768-dimensional context vectors.
_build_fc_layersBuilds the GELU + Dropout MLP classifier from pair-comparison features.
Winner Results

Validation and Calibration Summary

The final checkpoint was selected by validation F1, then the threshold was calibrated from the precision-recall curve.

01
Best calibrated F1Final threshold 0.4843311608
F1Score0.8131
Precision0.7834
Recall0.8452
02
Ranking qualityGood separation across the probability spectrum
AUROC0.9234
Avg Precision0.8719
Accuracy0.8566
03
Training budgetEarly stopping triggered at epoch 9 out of 50
Total Time147.02m
Avg / Epoch16.36m
DeviceCUDA
Experiment Tracking

MLflow as the Research Backbone

MLflow is treated as more than a metric logger: it gives the research a repeatable experiment protocol.

During research, MLflow was the control room for comparing many LSTM-attention variants. The custom MLflowTracker creates versioned run names, logs every epoch, stores parameters, captures the best checkpoint, and writes a compact summary for the selected model. For the public page, the important part is the methodology: each serious experiment has a comparable identity, config, metric history, and final calibrated result.

Configuration snapshotsToken, loader, train, model, and post-processing settings are captured so runs can be compared beyond final scores.
Architecture exportThe model-defining code is exported from the notebook so the champion architecture is not trapped inside exploratory cells.
Best checkpoint disciplineThe trainer keeps the strongest validation-F1 model instead of trusting the last epoch.
Tokenizer stateVocabulary, index mapping, stop-mask behavior, and padding rules are treated as part of the model package.
Dependency snapshotThe project records the third-party stack needed to reproduce the notebook environment.
Final run summaryBest F1, precision, recall, AUROC, Average Precision, threshold, and training time are summarized for the champion model.
Tracker Object

What the Custom MLflowTracker Automates

The project builds a small tracking abstraction that keeps each experiment consistent and comparable.

Run naming

Versioned experiment identity

The tracker builds run names from model_type and attention_type, then generates a versioned suffix. This keeps repeated experiments understandable during comparison.

Config logging

Full hyperparameter visibility

Each config section is logged to MLflow parameters. That includes tokenization, model dimensions, optimizer choices, scheduler settings, and training controls.

Epoch logging

Metric curves, not just final numbers

The tracker logs train_loss, val_loss, train/validation metrics, learning rate, and best_threshold at each epoch.

Artifact logging

Reproducible run package

The fit loop captures vocabulary state, label mapping, configs, exported architecture, dependency files, checkpoint state, the notebook, and the final summary.

Run Summary

Champion Experiment Snapshot

A concise public-facing view of the winner result and training controls.

FieldWinner Value
run_typeexploring-best-architecture
model_typeLSTM_attention
attention_typeMultiHead-Bahdanau
checkpoint_metricF1Score in max mode
early_stop_metricloss in min mode, patience 2
best_threshold0.4843311608
best_calibrated_f10.8131223321
Software Design

Notebook as a Mini NLP Framework

The strongest career signal in this project is the move from loose notebook cells to reusable, class-based research components.

Runtime configSystemConfig

Controls seed, deterministic mode, CUDA device selection, AMP scaler behavior, and tracking setup for the notebook runtime.

Path configPathConfig

Centralizes dataset, embedding, notebook, exported architecture, vocabulary, label mapping, config, history, and requirement paths through one object.

Data configTokenConfig, LoaderConfig

Defines <PAD>/<UNK>, maximum sequence length 50, vocabulary cap 20000, batch size 128, worker count, and pin-memory behavior.

Model configModelConfig

Defines the champion architecture: GloVe 100d, 2-layer BiLSTM, 4 attention heads, hidden dimension 384, FC stack, dropout, label smoothing, and feature-concat strategy.

Training configTrainConfig, PostProcessingConfig

Defines BCE training, early stopping, F1 checkpointing, ReduceLROnPlateau, gradient clipping, train split, unfreeze epoch, and inference/metric thresholds.

Pipeline classesQuoraPreproccesor, QuoraTokenizer, QuoraDataset, Trainer

Turns raw CSV rows into cleaned text, token IDs, tensors, loaders, calibrated metrics, checkpoints, summaries, and reproducible model packages.

Architecture classesAttentionHead, MultiHeadAttention, QuoraSiameseClassifier

Implements masked additive attention, multi-head pooling, shared Siamese encoding, pair feature construction, and final duplicate-logit prediction.

Training utilitiesEarlyStopping, TrainingHistory, MLflowTracker, BCEWithLabelSmoothing

Keeps training controlled, auditable, calibrated, and less overconfident through smoothed labels and run-level tracking.

Tokenizer System

Custom Tokenizer Inspired by Production NLP Pipelines

The tokenizer mirrors familiar NLP ecosystem ideas: build, encode, decode, save, load, and hydrate embeddings.

QuoraTokenizer responsibilities
class QuoraTokenizer:
    build_vocabs(df)        # count q1 + q2 tokens
    _build_stop_mask()      # create token-level stopword mask
    encode(text)            # ids, truncate, pad to max_length
    decode(ids)             # inspect encoded samples
    save(path)              # persist vocabs and masks
    load(path)              # restore tokenizer state
    load_embedding(dim)     # create GloVe matrix
Reusable asset

Vocabulary is part of the model package

The tokenizer state is treated as a first-class output, so inference can reuse the same token IDs, padding behavior, stop mask, vocabulary size, and index-to-word mapping.

The loaded GloVe embedding matrix is copied directly into the PyTorch embedding layer, with a staged training strategy: frozen at first, then unfrozen from epoch 4.

Dataset Object

QuoraDataset Converts Clean Text Into Model Batches

The dataset class keeps question-pair encoding and label packaging in one predictable interface.

QuoraDataset core behavior
class QuoraDataset(Dataset):
    def __init__(self, df, tokenizer):
        self.q1 = df["question1"].values
        self.q2 = df["question2"].values
        self.label = df["is_duplicate"].values
        self.tokenizer = tokenizer

    def __getitem__(self, idx):
        encoded_q1 = self.tokenizer.encode(self.q1[idx])
        encoded_q2 = self.tokenizer.encode(self.q2[idx])
        label = torch.tensor(self.label[idx], dtype=torch.float32)
        return {"q1": encoded_q1, "q2": encoded_q2, "label": label}
Batch contract

Every batch has a stable shape

Both questions are encoded to the same fixed length, so the trainer receives q1, q2, and label tensors without needing extra notebook-side glue code.

The dataset also includes a pos_class_weight helper, useful for experiments that need explicit positive-class weighting.

Trainer System

Training Loop With Calibration Built In

The trainer wraps optimization, metric computation, threshold search, scheduler behavior, checkpointing, and artifact logging.

01
Initialize metricsCreates torchmetrics objects for Accuracy, Precision, Recall, F1Score, AUROC, AveragePrecision, and a PrecisionRecallCurve for threshold search.
02
Train one epochRuns forward pass, smoothed BCE loss, optional AMP scaler, gradient clipping, optimizer step, and metric updates.
03
Evaluate and calibrateCollects validation probabilities, computes a PR curve, picks the threshold with best F1, then recomputes validation metrics at that threshold.
04
Control trainingSteps ReduceLROnPlateau, checks early stopping on validation loss, and saves the best checkpoint when validation F1 improves.
05
Finalize runLogs checkpoint folder, best model, final summary, final threshold, training time, notebook, and all reproducibility artifacts to MLflow.
Trainer.fit simplified structure
class Trainer:
    def fit(self, num_epochs):
        for epoch in range(num_epochs):
            if epoch == self.config.UNFREEZE_EPOCH:
                self._unfreeze_embedding()

            train_loss, train_results = self.train_one_epoch()
            val_loss, val_results = self.evaluate(use_optimal_threshold=True)

            self._check_scheduler(val_loss)
            self.history.update(train_loss, val_loss, train_results, val_results, self.optimizer)
            self.tracker.log_epoch(epoch, train_loss, val_loss, train_results, val_results, lr)
            self.tracker.log_metric("best_threshold", self.best_threshold, epoch)

            if self._is_checkpoint_better(val_results["F1Score"]):
                self.best_checkpoint_score = val_results["F1Score"]
                self.tracker.save_state_dict(self.model.state_dict(), checkpoint_path)

            if self.early_stopper.step(val_loss):
                break

        final_threshold, calibrated_results = self.find_optimal_threshold()
        self.tracker.log_summary(calibrated_results)
Automation Utilities

Requirements and Model Script Creators

Two utilities improve portability: dependency extraction and architecture export from the notebook.

requirements.py

A custom IPython magic scans notebook imports with ast, filters standard-library modules, maps imports to installed distributions, and updates requirements.txt. The winner artifact records 11 packages, including mlflow==3.9.0, torch==2.8.0+cu126, and torchmetrics==1.8.2.

export_model_from_notebook

The notebook extracts model-defining cells into model_architecture.py. That creates a deployable architecture script containing config and model classes, separate from the exploratory notebook itself.

Loss Design

BCE With Label Smoothing

The winner model uses binary cross entropy with smoothed labels to reduce overconfident duplicate/non-duplicate decisions.

BCEWithLabelSmoothing
class BCEWithLabelSmoothing(nn.Module):
    def __init__(self, epsilon, reduction="mean"):
        super().__init__()
        self.epsilon = epsilon
        self.criterion = nn.BCEWithLogitsLoss(reduction=reduction)

    def forward(self, logits, labels):
        labels = labels.float()
        labels = labels * (1 - self.epsilon) + (1 - labels) * self.epsilon
        return self.criterion(logits, labels)
Calibration

Why smoothing matters here

Duplicate-question detection contains many ambiguous examples: two questions can be lexically different but semantically close, or lexically similar but logically different. Label smoothing softens hard 0/1 targets, which helps the classifier avoid brittle confidence spikes.

The trainer then searches for the best F1 threshold on the validation precision-recall curve, so calibration is handled both in the loss and in post-processing.

Diagnostic Notebook

Error Analysis and Attention Inspection

The separate error-analysis notebook is raw and architecture-dependent, but the diagnostic thinking is important and was repeated across experiments.

Important caveat: the error-analysis notebook may contain code from one experiment while the final architecture changed later. So it should be read as a diagnostic methodology rather than a perfectly synchronized production evaluation script.

Core idea: after each architecture, inspect where the model fails, what probability ranges are poorly calibrated, what attention is focusing on, and whether pair features separate true duplicates from hard negatives.

False positive / false negative slicingSplits validation predictions into TP, TN, FP, and FN, then measures the rate and characteristics of each error type.
Probability and calibration analysisPlots probability distributions by class and calibration curves to see whether scores are meaningful probabilities or only rankings.
Manual attention inspectionRuns encoder steps manually to recover attention weights, decode tokens, and identify which words receive high attention.
Feature-space checksExtracts final pair features, projects them with PCA, and highlights FP/FN points to understand separation failure modes.
Length-based behaviorGroups examples by question length to inspect whether short, long, or asymmetric pairs create harder cases.
Threshold effectsStudies how thresholds interact with BCE, class imbalance, and F1 calibration rather than treating 0.5 as fixed truth.
Interpretability

How Attention Was Used for Debugging

Attention weights are not treated as absolute explanation, but they are useful for sanity-checking what the model emphasizes.

Token focus

Top attended tokens

The analysis decodes token IDs back to words and aggregates high-attention tokens across validation examples. This checks whether the model attends to meaningful semantic words or collapses into punctuation and stopwords.

Case review

Hard examples by error type

For false positives and true positives, the notebook prints question pairs, predicted probability or distance, and token-level attention values to compare what changed between correct and incorrect duplicate decisions.

Representation health

Variance and feature checks

The diagnostic code inspects LSTM outputs before attention and final feature vectors after similarity construction, looking for collapse, low variance, or poorly separated clusters.

Calibration thinking

From raw scores to deployable decisions

The winner training loop internalizes this lesson by searching the precision-recall curve and using the best threshold rather than assuming default sigmoid thresholding.

Final Synthesis

What This Project Demonstrates

The research value is both modeling depth and engineering maturity.

Modeling: the project implements attention manually, evaluates several attention/loss/matching families, and settles on a calibrated Siamese BiLSTM attention architecture.

Experimentation: MLflow gives the work a real lab structure: every meaningful run can carry parameters, metrics, artifacts, checkpoint files, and summaries.

Engineering: the code is no longer just notebook logic. It is organized into classes with clear responsibilities, closer to the style of reusable ML libraries.

Research direction: this foundation is designed to accept additional architecture families later, because the dataset layer, metric layer, tracker, and artifact packaging are already separated from the model implementation.

Amir Mohamad Askari · Quora Question Pairs Research Lab · 2026