Cross-Platform Actor Linking Pipeline
Build a modular NLP pipeline to link identities across simulated underground forums.
Introduction
30 Second Summary
People use different accounts across different platforms. Sometimes, researchers need to figure out which accounts belong to the same real-world person. This is a core challenge in security research, fraud detection, and online safety, because usernames, writing quirks, and posting habits all shift between communities.
In this project, you will build a research pipeline in Google Colab that takes posts from multiple simulated underground forums and identifies which accounts are secretly the same person based on writing style. You will measure how well your approach works using real evaluation metrics from the research literature.
What You'll Build
When you run the final cell, you will see a side-by-side comparison chart proving that your multi-post aggregation strategy dramatically outperforms naive per-post matching at linking identities across forums.
By the end of this project, you'll have:
- A synthetic dataset of 10 actors posting under different usernames across 3 forums, complete with noise, typos, and leet-speak, giving you reproducible ground truth you can re-run any time.
- A working identity linker that converts writing samples into 384-dimensional stylometric embeddings, computes similarity scores, and outputs ranked candidate matches across platforms.
- Quantitative proof via precision, recall, and Mean Reciprocal Rank scores plus a visual comparison chart showing that aggregating multiple posts per actor recovers correct links that single-post matching misses entirely.
- A modular pipeline you can re-run end-to-end with a single method call, swapping in different models or configurations without rewriting evaluation logic.
- Secret Mission: Add temporal posting pattern features as a second evidence signal and evaluate whether fusing temporal and stylometric signals improves linking accuracy.
Are there any prerequisites?
You need basic Python familiarity (variables, functions, loops). No machine learning or NLP experience is required. The project runs entirely in Google Colab's free tier with GPU access, so there is nothing to install locally.
Before We Start
Before diving into code, let's lock in what you're about to build and why it matters.
Cross-platform actor linking is a real research challenge. Understanding the problem first shapes every design decision in the pipeline ahead.
Set Up the Research Environment in Google Colab
Your actor linking pipeline needs to compute hundreds of text embeddings and run pairwise similarity comparisons across them.
That means you need a GPU-enabled environment with the right Python libraries before any research work can begin. Google Colab gives you free GPU access in a browser-based notebook, so there is nothing to install on your own machine.
In this step, get ready to:
- Create a new Google Colab notebook connected to a T4 GPU runtime.
- Install sentence-transformers, scikit-learn, matplotlib, and numpy.
- Verify all libraries import correctly and the GPU is accessible.
Create a new Colab notebook with GPU
Embedding models run orders of magnitude faster on a GPU than a CPU. Google Colab provides free T4 GPU access, which is more than enough for this project.
- Open colab.research.google.com in your browser.
- Click New notebook to create a fresh notebook.
- In the top menu, click Runtime.
- Select Change runtime type.
- In the Hardware accelerator dropdown, select T4 GPU.
- Click Save.
You should see a green checkmark or a T4 label in the top-right corner of the notebook, confirming you are connected to a GPU runtime.
Not seeing the T4 option?
Make sure you are signed into your Google account. The free tier may occasionally run out of GPU availability. If T4 is unavailable, try again after a few minutes or select a different GPU type if one is offered.
Why can't I connect to a GPU?
Install the required libraries
Your pipeline relies on four key libraries. sentence-transformers provides pre-trained models for converting text into numerical embeddings. scikit-learn handles similarity computation and evaluation metrics.
- In the first code cell of your notebook, install all four libraries by running:
!pip install -U sentence-transformers scikit-learn matplotlib numpy
What does this command do?
- The ! prefix tells Colab to run this as a shell command instead of Python code.
- -U ensures each package is upgraded to the latest compatible version.
- sentence-transformers provides the embedding model you will use to represent writing style as vectors.
- scikit-learn gives you cosine similarity computation and evaluation utilities.
The install takes 1-2 minutes. You should see output ending with Successfully installed followed by a list of package names.
Seeing a dependency conflict error?
Colab environments update periodically and can sometimes conflict with new package versions. If you see a red error about dependency resolution, click Runtime then Restart runtime, and re-run the install cell.
Help me fix my pip install error in Colab
Import libraries and verify GPU access
With the packages installed, you need to confirm Python can find them and that the GPU is actually available to your code.
- In a new code cell, import the libraries and check GPU access by running:
import numpy as np
import random
import matplotlib.pyplot as plt
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
import torch
# Check if GPU is available for embedding computation
print("GPU available:", torch.cuda.is_available())
What does this code do?
- numpy handles numerical arrays and matrix operations for embeddings.
- SentenceTransformer is the class you will use to load the embedding model and encode posts into vectors.
- cosine_similarity computes how similar two embedding vectors are on a scale from -1 to 1.
- torch.cuda.is_available() returns True if the GPU is accessible to PyTorch (which sentence-transformers uses under the hood).
You should see GPU available: True printed with no import errors above it.
Seeing GPU available: False?
Go back to Runtime then Change runtime type and confirm T4 GPU is selected. If it already is, click Runtime then Restart runtime and re-run both cells (the install and the import).
Why is my Colab GPU not detected?
Before you run the final check, what do you think you will see when you print the library versions?
- Verify the installed versions by running this in a new cell:
import sentence_transformers
print(sentence_transformers.__version__)
import sklearn
print(sklearn.__version__)
Both lines print version numbers without errors. You should see 5.7.0 (or similar) for sentence-transformers and 1.9.0 (or similar) for scikit-learn.
Your research environment is ready. Next up, you will build the synthetic data generator that creates the multi-forum actor profiles your pipeline will try to link.
Build the Synthetic Data Generator
Your environment is ready, and the embedding model library is installed. Now you need data to feed through the pipeline.
Real underground forum data is sensitive and access-restricted. A synthetic generator with known ground truth lets you develop and evaluate your pipeline reproducibly. The modular design means you can swap in real data later without rewriting anything.
In this step, get ready to:
- Build a DataIngestion class that generates synthetic actor profiles with distinct writing styles.
- Add noise injection (typos, leet-speak, variable lengths) to simulate real-world challenges.
- Run the generator and verify visible stylistic variation across actors and forums.
Define the class skeleton
The DataIngestion class is the first module of your pipeline. It generates synthetic actors who post across multiple forums under different usernames.
- Create a new code cell in your Colab notebook and paste the following class skeleton:
import numpy as np
import random
class DataIngestion:
"""Generates or loads multi-forum actor data with ground truth."""
def __init__(self, num_actors=10, posts_per_account=5, num_forums=3, seed=42):
self.num_actors = num_actors
self.posts_per_account = posts_per_account
self.num_forums = num_forums
self.seed = seed
def generate(self):
"""Generate synthetic multi-forum actor data."""
random.seed(self.seed)
np.random.seed(self.seed)
actor_styles = self._create_actor_styles()
forums_data = {}
ground_truth = {}
for forum_idx in range(self.num_forums):
forum_id = f"forum_{forum_idx}"
forums_data[forum_id] = {}
for actor_idx in range(self.num_actors):
username = self._generate_username(actor_idx, forum_idx)
posts = self._generate_posts(actor_styles[actor_idx])
forums_data[forum_id][username] = posts
if actor_idx not in ground_truth:
ground_truth[actor_idx] = {}
ground_truth[actor_idx][forum_id] = username
return {"forums": forums_data, "ground_truth": ground_truth}
What does this code do?
- The __init__ method sets configuration: 10 actors, 5 posts per account, 3 forums, and a fixed random seed for reproducibility.
- The generate method loops through each forum and each actor, creating a unique username and posts per forum. It returns a dictionary with all forum data plus a ground_truth mapping that links accounts to their true actor identity.
- The helper methods (_create_actor_styles, _generate_posts, etc.) are referenced here but defined next.
- Run this cell by pressing Shift+Enter.
- The cell executes without errors. The class is defined in memory, though you cannot call generate() yet because the helper methods do not exist.
Add actor style profiles
Each actor needs a consistent writing style that carries across forums. This is what makes identity linking possible. Different actors use different vocabulary, punctuation habits, and sentence structures.
- In the same cell, add the first part of the _create_actor_styles method below your generate method:
def _create_actor_styles(self):
"""Create distinct writing styles for each actor."""
topics = [
["exploit", "vulnerability", "patch", "zero-day", "buffer overflow"],
["crypto", "wallet", "transaction", "blockchain", "mining"],
["malware", "payload", "dropper", "obfuscation", "sandbox"],
["phishing", "spear", "credential", "social engineering", "pretexting"],
["botnet", "command", "control", "DDoS", "amplification"],
["ransomware", "encryption", "decrypt", "bitcoin", "payment"],
["carding", "dump", "track", "bin", "fullz"],
["proxy", "VPN", "tor", "anonymity", "OPSEC"],
["database", "SQL", "injection", "dump", "exfiltration"],
["firmware", "IoT", "embedded", "reverse", "debug"],
]
punctuation_styles = [
{"ending": ".", "emphasis": "!!", "ellipsis": False},
{"ending": "...", "emphasis": "!", "ellipsis": True},
{"ending": "", "emphasis": "??", "ellipsis": False},
{"ending": ".", "emphasis": " imo", "ellipsis": False},
{"ending": " -", "emphasis": " tbh", "ellipsis": True},
{"ending": ".", "emphasis": "!!", "ellipsis": False},
{"ending": "~", "emphasis": " lol", "ellipsis": True},
{"ending": ".", "emphasis": ".", "ellipsis": False},
{"ending": "", "emphasis": " fr", "ellipsis": False},
{"ending": ".", "emphasis": " ngl", "ellipsis": True},
]
What do these data structures represent?
Each actor gets a dedicated topic vocabulary (what they write about) and a punctuation style (how they end sentences, add emphasis). These two dimensions create a unique "fingerprint" for each actor's writing.
The 10 topic lists and 10 punctuation styles map one-to-one with the 10 actors, giving each actor a distinct combination.
- Directly below the punctuation_styles list (still inside the _create_actor_styles method), add the sentence templates and the style-building loop:
sentence_templates = [
["I tested {topic} and it works great", "Anyone tried {topic} recently", "New method for {topic} is solid"],
["been working on {topic} all week", "got a new approach to {topic}", "{topic} is getting harder these days"],
["Looking for help with {topic}", "Can someone explain {topic}", "Need advice on {topic} setup"],
["just dropped a guide on {topic}", "my {topic} tutorial is up", "check my new {topic} method"],
["warning about {topic} issues", "careful with {topic} lately", "heads up on {topic} problems"],
["{topic} market is crazy rn", "prices for {topic} going up", "anyone selling {topic} tools"],
["review of {topic} service", "tried {topic} from vendor X", "{topic} quality has dropped"],
["automation for {topic} done", "scripted the {topic} process", "bot handles {topic} now"],
["update on {topic} situation", "things changed with {topic}", "{topic} landscape shifting"],
["collab on {topic} project", "team needed for {topic}", "who wants to work on {topic}"],
]
styles = []
for i in range(self.num_actors):
styles.append({
"topics": topics[i % len(topics)],
"punctuation": punctuation_styles[i % len(punctuation_styles)],
"templates": sentence_templates[i % len(sentence_templates)],
"avg_length": random.randint(2, 5),
"uses_caps": random.choice([True, False]),
"leet_probability": random.uniform(0, 0.3),
})
return styles
What does the loop do?
The loop assigns each actor a combination of topics, punctuation style, sentence templates, average sentence count, capitalization preference, and a probability of leet-speak. These parameters drive the post generator to produce consistent-within-actor but distinct-between-actor text.
- Run the cell again with Shift+Enter.
- The cell still executes without errors. The class now has its style-generation logic, but still needs the post and username methods.
Add noise injection and post generation
Real forum posts are short, messy, and full of typos. Noise injection makes your synthetic data realistic and tests whether your linking pipeline can handle the same challenges the research literature identifies.
- In the same cell, add the _generate_posts method below _create_actor_styles:
def _generate_posts(self, style):
"""Generate posts matching an actor's style."""
posts = []
for _ in range(self.posts_per_account):
num_sentences = max(1, style["avg_length"] + random.randint(-1, 1))
sentences = []
for _ in range(num_sentences):
template = random.choice(style["templates"])
topic = random.choice(style["topics"])
sentence = template.format(topic=topic)
if style["uses_caps"]:
sentence = sentence.capitalize()
sentence += style["punctuation"]["ending"]
if random.random() < 0.3:
sentence += " " + style["punctuation"]["emphasis"]
sentences.append(sentence)
post = " ".join(sentences)
if random.random() < style["leet_probability"]:
post = self._apply_leet(post)
if random.random() < 0.2:
post = self._inject_typo(post)
posts.append(post)
return posts
How does noise get injected?
Each post is built sentence by sentence from the actor's templates and topics. After assembly, there is a random chance of leet-speak substitution (based on the actor's leet_probability) and a 20% chance of a random typo. This creates realistic variation within an actor's output.
- Add the final three helper methods below _generate_posts in the same cell:
def _generate_username(self, actor_idx, forum_idx):
"""Generate different usernames per forum for the same actor."""
prefixes = ["dark", "cyber", "x", "null", "ghost", "shadow", "net", "h4ck", "zero", "alpha"]
suffixes = ["_pro", "99", "_elite", "X", "_real", "001", "_z", "HD", "_v2", ""]
forum_suffixes = ["", "_f", str(forum_idx), ""]
prefix = prefixes[actor_idx % len(prefixes)]
suffix = suffixes[(actor_idx + forum_idx) % len(suffixes)]
fsuffix = forum_suffixes[forum_idx % len(forum_suffixes)]
return f"{prefix}{suffix}{fsuffix}"
def _apply_leet(self, text):
"""Apply leet speak substitutions."""
replacements = {"a": "4", "e": "3", "i": "1", "o": "0", "s": "5"}
for char, replacement in replacements.items():
if random.random() < 0.4:
text = text.replace(char, replacement)
return text
def _inject_typo(self, text):
"""Inject random typos."""
words = text.split()
if len(words) > 3:
idx = random.randint(0, len(words) - 1)
word = words[idx]
if len(word) > 2:
pos = random.randint(0, len(word) - 2)
word = word[:pos] + word[pos + 1] + word[pos] + word[pos + 2:]
words[idx] = word
return " ".join(words)
What do these helpers do?
- _generate_username gives each actor a different username per forum by combining prefixes and suffixes with forum-specific modifiers. The same actor becomes dark_pro on one forum and dark99_f on another.
- _apply_leet randomly replaces characters (a→4, e→3, etc.) to simulate informal forum writing.
- _inject_typo swaps two adjacent characters in a random word, simulating the kind of errors people make typing quickly.
- Run the cell with Shift+Enter.
- The cell executes without errors. Your complete DataIngestion class is now defined and ready to use.
Seeing an IndentationError?
Every method must be indented one level inside the class (4 spaces). The code inside each method needs a second level of indentation (8 spaces). Check that all the methods you added start at the same indentation level as __init__ and generate.
help me fix an IndentationError in my DataIngestion class
Run the generator and inspect the output
The class is defined. Now you need to call it and confirm the synthetic data looks right. You are looking for distinct writing styles between actors and consistent style within each actor's posts.
- Create a new cell below your class definition and run the generator by pasting this code:
# Instantiate and run the data generator
ingestion = DataIngestion()
data = ingestion.generate()
# Print summary stats
print(f"Forums: {list(data['forums'].keys())}")
print(f"Accounts per forum: {len(data['forums']['forum_0'])}")
print(f"Total actors in ground truth: {len(data['ground_truth'])}")
print()
# Show posts from two different actors on the same forum
forum_0_users = list(data['forums']['forum_0'].keys())
print(f"=== Actor 0 ({forum_0_users[0]}) ===")
for post in data['forums']['forum_0'][forum_0_users[0]][:2]:
print(f" {post}")
print(f"\n=== Actor 3 ({forum_0_users[3]}) ===")
for post in data['forums']['forum_0'][forum_0_users[3]][:2]:
print(f" {post}")
Before you run this, what do you expect to see? Think about how Actor 0 and Actor 3 should differ if the style system is working.
- Run the cell with Shift+Enter.
You should see 3 forums, 10 accounts per forum, and 10 actors in the ground truth. The two actors' posts look noticeably different. Actor 0 writes about exploits and vulnerabilities with period endings. Actor 3 writes about phishing and credentials with a different sentence structure.
What is the ground truth for?
The ground_truth dictionary maps each actor ID to their username on each forum. This is the answer key. When your linking engine predicts that account X on forum_0 matches account Y on forum_1, you check against ground truth to measure accuracy.
With real data, you would never have perfect ground truth. That is exactly why synthetic data is valuable for development.
- Verify the cross-forum username variation by adding and running this cell:
# Show how the same actor appears on different forums
print("=== Same actor across forums ===")
for forum_id, username in data['ground_truth'][0].items():
print(f" {forum_id}: {username}")
print(f" Sample post: {data['forums'][forum_id][username][0][:80]}...")
- Run the cell. You should see the same actor posting under different usernames across all three forums, with similar writing style in each post.
Seeing a KeyError or empty output?
Make sure you ran the class definition cell first (the one with class DataIngestion). If you restarted the runtime, all cells need to be re-run from the top.
help me debug a KeyError when accessing my generated data
✔️ Awesome, I've got everything!
Great. Double check you see distinct styles between actors and consistent style within an actor across forums.
ⓧ I'd like to double check the full code
Your class definition cell should contain all of the following, in this order:
- Imports: import numpy as np and import random.
- class DataIngestion: with the docstring.
- __init__ method setting num_actors, posts_per_account, num_forums, and seed.
- generate method with the forum/actor loop returning the data dictionary.
- _create_actor_styles method with topics, punctuation_styles, sentence_templates, and the styles loop.
- _generate_posts method building posts with style and noise.
- _generate_username, _apply_leet, and _inject_typo helper methods.
Scroll up and confirm each chunk is present in the cell, in order. Re-run the cell if you made corrections.
Your synthetic dataset is ready: 10 actors, 3 forums, 150 posts with realistic noise and a complete ground truth mapping. Next, you will feed these posts through an embedding model and attempt to link accounts using per-post similarity.
Embed Posts and Attempt Naive Per-Post Linking
In the previous step you generated a synthetic dataset with 10 actors posting across 3 forums. Now the question becomes: given posts from two different forums, can you figure out which accounts belong to the same person?
The simplest approach is to embed each post individually and compare them with cosine similarity. If two posts are stylistically similar, the accounts might be the same actor. But underground forum posts are short and noisy. Running this naive approach first will expose a core limitation that the research literature identifies.
In this step, get ready to:
- Load a transformer model and embed all posts into 384-dimensional vectors.
- Build a linking engine that compares posts across forums using cosine similarity.
- Evaluate the naive approach and observe its limitations on short, noisy text.
Load the model and embed posts individually
To compare writing styles mathematically, you need to convert raw text into numbers. A sentence-transformers model maps each post to a 384-dimensional vector that captures its semantic and stylistic properties.
- Create a new cell in your Colab notebook and define the FeatureExtractor class by pasting the following code:
# =============================================================================
# MODULE 2: FEATURE EXTRACTION
# =============================================================================
class FeatureExtractor:
"""Wraps an embedding model for stylometric feature extraction."""
def __init__(self, model_name="sentence-transformers/all-MiniLM-L6-v2"):
self.model_name = model_name
self.model = SentenceTransformer(model_name)
def embed_posts(self, posts):
"""Embed a list of posts individually."""
return self.model.encode(posts, convert_to_numpy=True)
def embed_account_aggregated(self, posts):
"""Embed all posts from one account and aggregate via mean pooling."""
individual_embeddings = self.embed_posts(posts)
aggregated = np.mean(individual_embeddings, axis=0)
norm = np.linalg.norm(aggregated)
if norm > 0:
aggregated = aggregated / norm
return aggregated
What does this code do?
The FeatureExtractor wraps the all-MiniLM-L6-v2 model (a small, fast transformer with 22.7M parameters) behind a clean interface.
- embed_posts() encodes each post independently into a 384-dimensional numpy array. This is what you will use in this step.
- embed_account_aggregated() averages all post embeddings from one account into a single vector. You will use this in the next step to improve results.
- Run the cell to define the class. Then create a new cell below to embed posts from two forums and verify the output shape:
# Generate data from the previous step
ingestion = DataIngestion(num_actors=10, posts_per_account=5, num_forums=3, seed=42)
data = ingestion.generate()
# Load model and embed posts from forum_0 and forum_1
extractor = FeatureExtractor()
source_forum = "forum_0"
target_forum = "forum_1"
# Embed each post individually for both forums
naive_source = {}
for username, posts in data["forums"][source_forum].items():
naive_source[username] = extractor.embed_posts(posts)
naive_target = {}
for username, posts in data["forums"][target_forum].items():
naive_target[username] = extractor.embed_posts(posts)
# Check the embedding shape for one account
sample_user = list(naive_source.keys())[0]
print(f"Account: {sample_user}")
print(f"Embeddings shape: {naive_source[sample_user].shape}")
You should see output like Embeddings shape: (5, 384) confirming that each account's 5 posts are now represented as 5 separate 384-dimensional vectors.
Seeing a CUDA error or slow download?
- If the model download hangs, check your Colab internet connection by clicking the RAM/Disk indicator in the top right. Reconnect if needed.
- If you see a CUDA out-of-memory error, go to Runtime > Restart runtime and re-run all cells from the top. The model is small but stale memory from other operations can interfere.
Why aren't my embeddings the right shape?
Build the naive linking engine
With embeddings in hand, the next piece is a LinkingEngine that compares every post from one account against every post from another. For each account in Forum A, it picks the Forum B account whose single most-similar post scores highest.
- Create a new cell and define the LinkingEngine class with its link_naive method:
# =============================================================================
# MODULE 3: LINKING ENGINE
# =============================================================================
class LinkingEngine:
"""Computes pairwise similarities and ranks candidate matches."""
def __init__(self, threshold=0.0):
self.threshold = threshold
def link_naive(self, forum_a_posts, forum_b_posts):
"""Link accounts using per-post max similarity (naive approach)."""
results = {}
for username_a, posts_a in forum_a_posts.items():
best_match = None
best_score = -1
for username_b, posts_b in forum_b_posts.items():
embeddings_a = np.array(posts_a)
embeddings_b = np.array(posts_b)
sim_matrix = cosine_similarity(embeddings_a, embeddings_b)
max_sim = np.max(sim_matrix)
if max_sim > best_score:
best_score = max_sim
best_match = username_b
if best_score >= self.threshold:
results[username_a] = {"match": best_match, "score": float(best_score)}
return results
What does the naive linker do?
- For each account in Forum A, it builds a full similarity matrix (5 posts x 5 posts) against every Forum B account.
- It takes the single highest similarity score from that matrix as the "match strength" between two accounts.
- The Forum B account with the highest max-similarity becomes the predicted link. This is the weakest possible signal because one lucky post-pair can dominate.
- Now add the link_aggregated method below link_naive in the same cell. You will use this in the next step:
def link_aggregated(self, forum_a_embeddings, forum_b_embeddings):
"""Link accounts using aggregated embeddings."""
usernames_a = list(forum_a_embeddings.keys())
usernames_b = list(forum_b_embeddings.keys())
embeddings_a = np.array([forum_a_embeddings[u] for u in usernames_a])
embeddings_b = np.array([forum_b_embeddings[u] for u in usernames_b])
sim_matrix = cosine_similarity(embeddings_a, embeddings_b)
results = {}
for i, username_a in enumerate(usernames_a):
ranked_indices = np.argsort(sim_matrix[i])[::-1]
ranked_matches = [
{"username": usernames_b[j], "score": float(sim_matrix[i][j])}
for j in ranked_indices
]
results[username_a] = ranked_matches
return results
Why define link_aggregated now?
You are building a modular pipeline where each component has a consistent interface. Defining both methods now keeps the class complete. You will call link_aggregated in the next step when you implement multi-post aggregation.
- Run the cell to define the class. Then create a new cell to run the naive linker and inspect a sample prediction:
# Run naive linking between forum_0 and forum_1
linker = LinkingEngine()
naive_predictions = linker.link_naive(naive_source, naive_target)
# Print a few predictions
for i, (user, pred) in enumerate(naive_predictions.items()):
if i >= 3:
break
print(f"{user} -> {pred['match']} (score: {pred['score']:.4f})")
print(f"\nTotal predictions: {len(naive_predictions)}")
You should see 10 predictions (one per account in forum_0) with similarity scores. The matches look confident (scores above 0.8) but that does not mean they are correct.
Seeing zero predictions?
- Make sure you ran the embedding cell first. The naive_source and naive_target dictionaries must already exist.
- Confirm that LinkingEngine is defined in a cell above this one and has been run.
Help me debug why my naive linker returns no predictions.
Evaluate against ground truth
High similarity scores do not mean correct links. You need to check predictions against the ground truth to measure actual accuracy. The Evaluator class computes three standard metrics from the identity resolution literature: precision, recall, and Mean Reciprocal Rank (MRR).
- Create a new cell and define the Evaluator class with the evaluate_top1 method:
# =============================================================================
# MODULE 4: EVALUATION
# =============================================================================
class Evaluator:
"""Computes precision, recall, MRR and generates comparison plots."""
def evaluate_top1(self, predictions, ground_truth, source_forum, target_forum):
correct = 0
total = 0
reciprocal_ranks = []
for actor_id, forum_map in ground_truth.items():
if source_forum not in forum_map or target_forum not in forum_map:
continue
source_username = forum_map[source_forum]
target_username = forum_map[target_forum]
if source_username in predictions:
ranked = predictions[source_username]
total += 1
if isinstance(ranked, dict):
if ranked.get("match") == target_username:
correct += 1
reciprocal_ranks.append(1.0)
else:
reciprocal_ranks.append(0.0)
elif isinstance(ranked, list):
found_rank = None
for rank, candidate in enumerate(ranked):
if candidate["username"] == target_username:
found_rank = rank + 1
break
if found_rank == 1:
correct += 1
if found_rank is not None:
reciprocal_ranks.append(1.0 / found_rank)
else:
reciprocal_ranks.append(0.0)
precision = correct / total if total > 0 else 0
recall = correct / len(ground_truth) if len(ground_truth) > 0 else 0
mrr = np.mean(reciprocal_ranks) if reciprocal_ranks else 0
return {"precision": precision, "recall": recall, "mrr": mrr, "total": total}
What do these metrics measure?
- Precision: Of all predictions made, what fraction are correct? A precision of 0.3 means only 30% of links are right.
- Recall: Of all true links that exist, what fraction did we find? Low recall means we missed real connections.
- MRR (Mean Reciprocal Rank): When the correct match is in our ranked list, how high does it rank on average? An MRR of 1.0 means the correct match is always first. An MRR of 0.5 means it is second on average.
- Now add the compare_results and plot_comparison methods below evaluate_top1 in the same cell:
def compare_results(self, results_dict):
print("\n" + "=" * 60)
print(f"{'Approach':<25} {'Precision':<12} {'Recall':<12} {'MRR':<12}")
print("=" * 60)
for name, metrics in results_dict.items():
print(f"{name:<25} {metrics['precision']:<12.4f} {metrics['recall']:<12.4f} {metrics['mrr']:<12.4f}")
print("=" * 60)
def plot_comparison(self, results_dict):
approaches = list(results_dict.keys())
metrics = ["precision", "recall", "mrr"]
x = np.arange(len(metrics))
width = 0.8 / len(approaches)
fig, ax = plt.subplots(figsize=(10, 6))
for i, approach in enumerate(approaches):
values = [results_dict[approach][m] for m in metrics]
ax.bar(x + i * width, values, width, label=approach)
ax.set_xlabel("Metric")
ax.set_ylabel("Score")
ax.set_title("Cross-Platform Actor Linking: Approach Comparison")
ax.set_xticks(x + width * (len(approaches) - 1) / 2)
ax.set_xticklabels(["Precision", "Recall", "MRR"])
ax.legend()
ax.set_ylim(0, 1.1)
plt.tight_layout()
plt.show()
What do these methods do?
- compare_results() prints a formatted table comparing precision, recall, and MRR across different approaches.
- plot_comparison() generates a grouped bar chart for visual comparison. You will use this in the next step when you have two approaches to compare.
- Run the Evaluator cell to define the class.
Before running the evaluation, think about this: you have 10 accounts in forum_0 being matched against 10 accounts in forum_1. The naive linker picks its best match based on whichever single post-pair scored highest. Do you think precision will be above or below 0.5?
- Create a new cell and run the evaluation to find out:
# Evaluate naive linking against ground truth
evaluator = Evaluator()
naive_metrics = evaluator.evaluate_top1(
naive_predictions, data["ground_truth"], source_forum, target_forum
)
# Print the results
results = {"Naive (per-post)": naive_metrics}
evaluator.compare_results(results)
You will see a results table where precision is below 0.5 and MRR is poor. The naive approach fails because individual short posts carry too little stylistic signal. A single post about "crypto wallets" could match any account that mentioned crypto, regardless of the author's actual writing patterns.
Why does the naive approach fail?
Underground forum posts are typically 1-3 sentences long. At that length, topic dominates over style. Two different people discussing the same subject produce similar embeddings even though their writing patterns differ.
The research literature (VendorLink, SYSML, MultFS) converges on a key insight: you need multiple posts aggregated together to extract a reliable stylistic fingerprint. That is exactly what you will implement in the next step.
Getting precision of 0.0?
- Make sure you passed data["ground_truth"] (not data["forums"]) as the second argument to evaluate_top1.
- Confirm that source_forum is set to "forum_0" and target_forum is set to "forum_1". These must match the keys in the ground truth dictionary.
Help me understand why my evaluator shows 0.0 precision for naive linking.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all four cells (FeatureExtractor, LinkingEngine, embedding/linking execution, and Evaluator) in order from top to bottom.
ⓧ I'd like to double check the full code
Your notebook should contain the following cells in order. Scroll up and confirm each is present:
- Cell 1: Imports and DataIngestion class (from Step 2).
- Cell 2: FeatureExtractor class with embed_posts and embed_account_aggregated methods.
- Cell 3: LinkingEngine class with link_naive and link_aggregated methods.
- Cell 4: Data generation, embedding, and naive linking execution (creates naive_source, naive_target, and naive_predictions).
- Cell 5: Evaluator class with evaluate_top1, compare_results, and plot_comparison methods.
- Cell 6: Evaluation execution that prints the results table.
The naive approach has exposed the problem: individual short posts are not enough to fingerprint an actor. In the next step, you will aggregate multiple posts per account into a single robust representation and watch the metrics jump.
Implement Multi-Post Aggregation and Re-Evaluate
In the previous step, your naive approach produced poor results. Individual forum posts are too short and noisy to carry a reliable stylistic fingerprint.
Research papers like VendorLink and SYSML converge on the same insight: aggregate multiple posts per actor into a single robust representation. By averaging the embeddings of all posts from one account, noise cancels out and the true stylistic signal emerges. This is the fix that makes cross-platform linking viable.
In this step, get ready to:
- Implement mean pooling to aggregate multiple post embeddings into one actor representation.
- Run aggregated linking between forums and rank candidate matches.
- Evaluate the improvement with a comparison table and bar chart.
Aggregate post embeddings with mean pooling
A single short post has too much noise to reveal an author's style. But if you average the embeddings of five posts from the same account, random noise cancels out and the consistent stylistic patterns remain.
- In your Colab notebook, find the FeatureExtractor class cell. Add this method below embed_posts:
def embed_account_aggregated(self, posts):
"""Embed all posts from one account and aggregate via mean pooling."""
individual_embeddings = self.embed_posts(posts)
aggregated = np.mean(individual_embeddings, axis=0)
norm = np.linalg.norm(aggregated)
if norm > 0:
aggregated = aggregated / norm
return aggregated
What does this code do?
This method takes all posts from a single account, embeds each one individually, then averages all the embedding vectors together. The result is one 384-dimensional vector that represents the account's overall writing style.
The L2 normalization at the end (dividing by the vector's magnitude) ensures all aggregated embeddings sit on the unit sphere. This makes cosine similarity comparisons fair regardless of how many posts an account has.
- Re-run the FeatureExtractor class cell so Python picks up the new method.
- In a new cell, compute aggregated embeddings for all accounts by running this code:
# Compute one aggregated embedding per account
source_forum = "forum_0"
target_forum = "forum_1"
agg_source = {}
agg_target = {}
for username, posts in data["forums"][source_forum].items():
agg_source[username] = extractor.embed_account_aggregated(posts)
for username, posts in data["forums"][target_forum].items():
agg_target[username] = extractor.embed_account_aggregated(posts)
# Verify shape and count
first_key = list(agg_source.keys())[0]
print(f"Aggregated {len(agg_source)} accounts from {source_forum}")
print(f"Aggregated {len(agg_target)} accounts from {target_forum}")
print(f"Embedding shape: {agg_source[first_key].shape}")
What's happening here?
- Each account's 5 posts are encoded into 5 separate embeddings, then averaged into one vector.
- The result is a dictionary mapping each username to a single 384-dimensional representation of their writing style.
- Run the cell. You should see output confirming 10 accounts aggregated from each forum, with an embedding shape of (384,).
Seeing an AttributeError?
- If you see 'FeatureExtractor' object has no attribute 'embed_account_aggregated', you forgot to re-run the class definition cell after adding the method.
- After re-running the class cell, also re-run the cell that creates extractor = FeatureExtractor() so the instance picks up the new method.
- Still stuck? Help me debug this AttributeError in my Colab notebook.
Link accounts using aggregated embeddings
Now you have one clean embedding per account. Instead of comparing messy individual posts, you compare these stable representations. The linking engine computes a full similarity matrix and ranks every candidate match.
- Find your LinkingEngine class cell. Add this method below link_naive:
def link_aggregated(self, forum_a_embeddings, forum_b_embeddings):
"""Link accounts using aggregated embeddings."""
usernames_a = list(forum_a_embeddings.keys())
usernames_b = list(forum_b_embeddings.keys())
embeddings_a = np.array([forum_a_embeddings[u] for u in usernames_a])
embeddings_b = np.array([forum_b_embeddings[u] for u in usernames_b])
sim_matrix = cosine_similarity(embeddings_a, embeddings_b)
results = {}
for i, username_a in enumerate(usernames_a):
ranked_indices = np.argsort(sim_matrix[i])[::-1]
ranked_matches = [
{"username": usernames_b[j], "score": float(sim_matrix[i][j])}
for j in ranked_indices
]
results[username_a] = ranked_matches
return results
What does this code do?
- It stacks all aggregated embeddings into matrices and computes pairwise cosine similarity in one call. This gives a 10x10 similarity matrix.
- For each source account, it sorts all target accounts by similarity score from highest to lowest.
- The output is a ranked list of candidates for every account, not just the top-1 match. This lets the evaluator compute Mean Reciprocal Rank over the full ranking.
- Re-run the LinkingEngine class cell.
- In a new cell, run the aggregated linking and inspect the top match by running this code:
# Run aggregated linking
linker = LinkingEngine()
agg_predictions = linker.link_aggregated(agg_source, agg_target)
# Inspect top match for the first account
first_account = list(agg_predictions.keys())[0]
print(f"Account: {first_account}")
print(f"Top match: {agg_predictions[first_account][0]}")
print(f"Runner-up: {agg_predictions[first_account][1]}")
- Run the cell. You should see a top match with a similarity score, plus a runner-up with a noticeably lower score. The gap between the two indicates the linker is more confident in its matches now.
Seeing identical scores for all candidates?
- Make sure you re-ran the cell that creates agg_source and agg_target after updating the FeatureExtractor class.
- Check that embed_account_aggregated includes the L2 normalization step. Without it, cosine similarity can produce degenerate values.
- Help me debug why my aggregated linking shows identical scores.
Evaluate and visualize the improvement
You have both sets of predictions: the naive per-post results from Step 3 and the aggregated results you just computed. Time to put them side by side and see whether mean pooling actually made a difference.
Before you run the evaluation, think about this: do you expect the aggregated approach to perform significantly better than naive, or just slightly better? Why?
- Run the full evaluation and comparison by adding this code in a new cell:
# Evaluate aggregated approach against ground truth
evaluator = Evaluator()
agg_metrics = evaluator.evaluate_top1(
agg_predictions, data["ground_truth"], source_forum, target_forum
)
# Build comparison dictionary
results = {
"Naive (per-post)": naive_metrics,
"Aggregated (multi-post)": agg_metrics,
}
# Print side-by-side comparison table
evaluator.compare_results(results)
# Generate bar chart
evaluator.plot_comparison(results)
What does this code do?
- evaluate_top1 checks each predicted link against ground truth and computes precision, recall, and MRR.
- compare_results prints a formatted table showing both approaches side by side.
- plot_comparison generates a grouped bar chart so you can see the improvement at a glance.
- Run the cell. You should see a comparison table where the aggregated approach shows precision above 0.75 and a much higher MRR compared to the naive results. A bar chart renders below the table, making the gap between the two approaches visually obvious.
Why is the improvement so large?
Each individual post is short and noisy. Random word choices, typos, and abbreviations dominate the embedding. But when you average five posts together, those random variations cancel out.
What remains is the consistent signal: an actor's preferred vocabulary, sentence length, and punctuation habits. This is the same principle behind why survey researchers average multiple responses to get a reliable measure.
NameError on naive_metrics?
- The variable naive_metrics was created in Step 3. If your Colab runtime restarted, you need to re-run all cells from Step 3 that define data, extractor, and naive_metrics.
- Use **Runtime > Run all** in Google Colab to re-execute every cell in order.
- Help me fix my NameError in the evaluation cell.
✔️ Awesome, I've got everything!
Great. Your aggregated approach should be clearly outperforming the naive baseline in all three metrics.
ⓧ I'd like to double check the full code
Scroll up and confirm your class cells contain these methods in order:
- FeatureExtractor class with both embed_posts and embed_account_aggregated methods.
- LinkingEngine class with both link_naive and link_aggregated methods.
- Execution cells that compute agg_source / agg_target, run link_aggregated, and print the comparison table plus bar chart.
You now have quantitative proof that multi-post aggregation solves the core weakness of per-post linking. Next up, you will package all of these components into a clean, modular pipeline that runs end-to-end with a single method call.
Package as a Modular Research Pipeline
In the previous step, you proved that multi-post aggregation dramatically outperforms naive per-post linking. You have a comparison table and chart showing the improvement.
Right now your pipeline lives as scattered cells in a notebook. A collaborator would need to run each cell in the exact right order and manually wire outputs between stages. In this step, you will wrap everything into a single Pipeline class that runs the full experiment from one method call, accepts configuration, and makes changing the model a one-line edit.
In this step, get ready to:
- Create a Pipeline class that orchestrates all four existing components with a config dictionary.
- Run the full experiment end-to-end from a single method call.
- Demonstrate extensibility by comparing results across different configurations.
Create the Pipeline orchestrator class
Your four modules already have consistent interfaces. Each one takes configuration in its constructor and exposes a clear method to produce results. The missing piece is an orchestrator that wires them together.
- Create a new cell below your existing code and define the Pipeline class with its configuration by pasting the following:
# =============================================================================
# MODULE 5: PIPELINE ORCHESTRATOR
# =============================================================================
class Pipeline:
"""Orchestrates the full actor linking research pipeline."""
def __init__(self, config=None):
self.config = config or {
"model_name": "sentence-transformers/all-MiniLM-L6-v2",
"num_actors": 10,
"posts_per_account": 5,
"num_forums": 3,
"threshold": 0.0,
"seed": 42,
}
What does this code do?
The Pipeline class stores a configuration dictionary with sensible defaults. Every parameter that affects the experiment is declared here in one place.
To swap the embedding model, you change model_name in this dictionary. To adjust the linking sensitivity, you change threshold. No other code needs editing.
Add the run method to execute all stages
The run method will call each of your four modules in sequence, passing outputs from one stage as inputs to the next.
- In the same cell, directly below the __init__ method, add the first half of the run method:
def run(self):
print("=" * 60)
print("CROSS-PLATFORM ACTOR LINKING RESEARCH PIPELINE")
print("=" * 60)
# Stage 1: Data Generation
print("\n[1/4] Generating synthetic data...")
ingestion = DataIngestion(
num_actors=self.config["num_actors"],
posts_per_account=self.config["posts_per_account"],
num_forums=self.config["num_forums"],
seed=self.config["seed"],
)
data = ingestion.generate()
print(f" Generated {self.config['num_actors']} actors across {self.config['num_forums']} forums")
# Stage 2: Feature Extraction
print("\n[2/4] Extracting features...")
extractor = FeatureExtractor(model_name=self.config["model_name"])
source_forum = "forum_0"
target_forum = "forum_1"
# Naive: embed posts individually
naive_source = {}
naive_target = {}
for username, posts in data["forums"][source_forum].items():
naive_source[username] = extractor.embed_posts(posts)
for username, posts in data["forums"][target_forum].items():
naive_target[username] = extractor.embed_posts(posts)
What does this code do?
Stage 1 instantiates your DataIngestion class using config values, then generates the synthetic dataset. Stage 2 creates a FeatureExtractor using whatever model name is in the config and starts embedding posts individually for the naive comparison.
Notice how every parameter comes from self.config instead of being hardcoded. This is what makes the pipeline configurable.
- Continue in the same cell directly below the previous code. Add the rest of the run method:
# Aggregated: embed and aggregate
agg_source = {}
agg_target = {}
for username, posts in data["forums"][source_forum].items():
agg_source[username] = extractor.embed_account_aggregated(posts)
for username, posts in data["forums"][target_forum].items():
agg_target[username] = extractor.embed_account_aggregated(posts)
print(f" Embedded posts from {source_forum} and {target_forum}")
# Stage 3: Linking
print("\n[3/4] Running linking algorithms...")
linker = LinkingEngine(threshold=self.config["threshold"])
naive_predictions = linker.link_naive(naive_source, naive_target)
agg_predictions = linker.link_aggregated(agg_source, agg_target)
# Stage 4: Evaluation
print("\n[4/4] Evaluating results...")
evaluator = Evaluator()
naive_metrics = evaluator.evaluate_top1(
naive_predictions, data["ground_truth"], source_forum, target_forum
)
agg_metrics = evaluator.evaluate_top1(
agg_predictions, data["ground_truth"], source_forum, target_forum
)
results = {
"Naive (per-post)": naive_metrics,
"Aggregated (multi-post)": agg_metrics,
}
evaluator.compare_results(results)
evaluator.plot_comparison(results)
return results
What does this code do?
This completes the four-stage pipeline. After computing aggregated embeddings, Stage 3 runs both linking algorithms through your LinkingEngine. Stage 4 evaluates both approaches using the Evaluator and produces the comparison table plus the bar chart.
The method returns the results dictionary so you can capture and reuse it for cross-config comparisons.
- Run this cell to define the Pipeline class. You should see no output (the class is just defined, not yet executed).
Seeing an IndentationError?
Make sure the run method is indented one level inside the Pipeline class, and all the code inside run is indented two levels. The class definition, __init__, and run must all be in the same cell.
help me fix my Pipeline class indentation
Run the full pipeline end-to-end
With the Pipeline class defined, you can now execute the entire experiment from a single call.
- Create a new cell and run the pipeline by pasting this code:
# Run the full pipeline with default configuration
pipeline = Pipeline()
results = pipeline.run()
Before you run this cell, what do you expect to see? Think about what four stages the pipeline will print as it executes.
- Run the cell.
You should see the pipeline print progress for all four stages, then display the comparison table and bar chart. The results match what you saw in Step 4, but now everything ran from one call.
Why does this matter for research?
Your collaborator can now reproduce your entire experiment by running two lines of code. They do not need to understand the internal wiring between stages.
To swap the embedding model, they change exactly one value in the config dictionary: model_name. Every other component adapts automatically.
Seeing a NameError for DataIngestion or FeatureExtractor?
The Pipeline class relies on all four module classes being already defined in your notebook. Make sure the cells containing DataIngestion, FeatureExtractor, LinkingEngine, and Evaluator have all been run before you execute the Pipeline cell.
help me fix a NameError in my Pipeline class
Demonstrate extensibility with different configurations
A modular pipeline proves its value when you can run multiple experiments without rewriting code. You will now run a second experiment with a stricter similarity threshold and compare both results.
- Create a new cell and run the pipeline with a different configuration by pasting this code:
# Run a second experiment with a stricter threshold
config_strict = {
"model_name": "sentence-transformers/all-MiniLM-L6-v2",
"num_actors": 10,
"posts_per_account": 5,
"num_forums": 3,
"threshold": 0.5,
"seed": 42,
}
pipeline_strict = Pipeline(config=config_strict)
results_strict = pipeline_strict.run()
What changed in this configuration?
The only difference is threshold changed from 0.0 to 0.5. This means the naive linker will only accept matches where cosine similarity exceeds 0.5, filtering out weak candidates.
Comparing the two runs shows how threshold tuning affects precision and recall. A higher threshold may improve precision (fewer false positives) but hurt recall (some true matches get filtered out).
- Run the cell. You should see the full pipeline execute again with the stricter threshold and produce a new comparison table.
- Now compare both experiment results side by side in a new cell:
# Compare results from both configurations
print("\nCOMPARISON ACROSS CONFIGURATIONS")
print("=" * 60)
print(f"{'Config':<30} {'Precision':<12} {'Recall':<12} {'MRR':<12}")
print("=" * 60)
print(f"{'Default (threshold=0.0)':<30} {results['Aggregated (multi-post)']['precision']:<12.4f} {results['Aggregated (multi-post)']['recall']:<12.4f} {results['Aggregated (multi-post)']['mrr']:<12.4f}")
print(f"{'Strict (threshold=0.5)':<30} {results_strict['Aggregated (multi-post)']['precision']:<12.4f} {results_strict['Aggregated (multi-post)']['recall']:<12.4f} {results_strict['Aggregated (multi-post)']['mrr']:<12.4f}")
print("=" * 60)
- Run this cell. You should see a table comparing the aggregated approach under both threshold settings, showing how configuration changes affect results.
What does this prove?
You just ran two full experiments by changing one value in a dictionary. No code was duplicated, no cells were re-ordered. This is the power of a modular pipeline: your collaborator can systematically explore the parameter space, swap in different models, or plug in real data without touching the evaluation logic.
Before you move on, confirm this: if you wanted to test a completely different embedding model, which single line in the config would you change?
The answer is model_name. That one-line-swap design is exactly what makes this pipeline research-ready.
✔️ Awesome, I've got everything!
Great. Confirm you have saved the cell containing the full Pipeline class and that both experiment runs produced output.
ⓧ I'd like to double check the full code
Your Pipeline class cell should contain these sections in order:
- The module header comment (MODULE 5: PIPELINE ORCHESTRATOR).
- The Pipeline class with its docstring and __init__ method containing the config dictionary with 6 keys.
- The run method with four printed stages: data generation, feature extraction, linking, and evaluation.
- The method ends with evaluator.compare_results(results), evaluator.plot_comparison(results), and return results.
Scroll up through your notebook and confirm all five module cells are present (DataIngestion, FeatureExtractor, LinkingEngine, Evaluator, Pipeline).
Your pipeline is complete. You now have a modular, configurable research tool that runs end-to-end from a single call. Next up: the Secret Mission challenges you to extend this pipeline with a completely new feature signal.
Secret mission
Fuse Temporal Posting Patterns with Stylometric Features
Your pipeline currently uses only text-based stylometry for linking. Research shows that temporal posting patterns (when someone posts, how bursty their activity is) are a powerful complementary signal. Can you add a second feature extraction pathway, fuse both signals, and measure whether the combination outperforms stylometry alone?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely on Google Colab's free tier, so there are no ongoing costs.
Resources you used:
- Google Colab notebook (saved in your Google Drive).
- GPU runtime session (ephemeral, resets on disconnect).
- Installed packages and cached model (ephemeral, cleared when runtime disconnects).
Keep everything running
No action needed. Choose this if you are still actively experimenting with the pipeline or extending it with new data sources.
- Your notebook stays in Google Drive and you can reopen it anytime.
- The GPU runtime will automatically disconnect after a period of inactivity, freeing the session.
- Packages and the cached model will need to be reinstalled the next time you reconnect a runtime.
Pause - I'll come back to this later
Disconnect the runtime to free up the GPU session while keeping your notebook saved.
- In your Colab notebook, click Runtime in the top menu.
- Click Disconnect and delete runtime.
- Your notebook code stays saved in Google Drive. The installed packages and model cache are cleared.
- When you come back, reconnect the runtime and re-run the install cell to restore your environment.
Delete - I don't want to use this again
Remove the notebook from your Google Drive entirely.
- In your Colab notebook, click Runtime in the top menu, then click Disconnect and delete runtime.
- Open Google Drive at drive.google.com.
- Navigate to the Colab Notebooks folder.
- Right-click your actor linking notebook and select Move to trash.
- Empty your trash to permanently remove the file.
Nice Work!
Nice Work!
You just built a complete, modular research pipeline for cross-platform actor linking. From synthetic data generation to evaluated identity resolution, you now have a reproducible tool that tackles one of the hardest problems in underground community research.
Here's what you learned:
- Built a synthetic data generator that produces multi-forum actor profiles with realistic noise, distinct writing styles, and known ground truth labels for reproducible experiments.
- Discovered that naive per-post embedding fails on short, noisy text, then implemented multi-post aggregation (mean pooling) and measured a significant improvement in precision and Mean Reciprocal Rank.
- Packaged the entire workflow into a modular pipeline with four swappable components (DataIngestion, FeatureExtractor, LinkingEngine, Evaluator) that runs end-to-end from a single method call.
- Secret Mission: Added temporal posting patterns as a second evidence signal and evaluated multi-modal fusion, demonstrating the pipeline's extensibility to new feature types.
Ready to quiz yourself?