rmi-backend/scripts/finetune_embeddings.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

293 lines
10 KiB
Python

#!/usr/bin/env python3
"""
Domain Fine-Tuned Embeddings - MarginMSE with hard negative mining.
Trains scam-specific embeddings that outperform general-purpose models
for crypto security tasks. Uses cross-encoder as teacher, hard negative
mining from retrieval errors, MarginMSE loss for optimization.
Output: Fine-tuned sentence-transformer model saved to /app/data/models/
Usage:
python3 scripts/finetune_embeddings.py --epochs 3 --batch-size 16
Requirements:
- Redis with existing RAG documents (for hard negative mining)
- Cross-encoder model (for teacher scores)
- Training data: scam queries + positive/negative document pairs
"""
import argparse
import asyncio
import logging
import os
import random
import sys
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# ── Training data: query → [positive_doc, negative_docs...] ─────
SCAM_QUERIES = [
(
"What are rug pull indicators?",
[
"Rug pulls involve liquidity removal, disabled transfers, and anonymous teams. Look for: large LP withdrawals, deployer selling tokens, and social media going dark.",
],
),
(
"How to detect honeypot tokens?",
[
"Honeypot tokens prevent selling. Check for: transfer restrictions, max transaction limits, and sell taxes above 50%. Use honeypot.is or tokensniffer for automated checks.",
],
),
(
"What is a flash loan attack?",
[
"Flash loan attacks exploit uncollateralized lending. Attackers borrow large amounts, manipulate oracle prices, drain funds, and repay within one transaction. Crema Finance lost $8.8M to this.",
],
),
(
"Phishing scam detection",
[
"Phishing scams use fake websites mimicking DEXs. Signs: slightly different URLs, requests for seed phrases, fake airdrop claims. Always verify contract addresses on official sources.",
],
),
(
"Wash trading identification",
[
"Wash trading creates fake volume. Indicators: circular token flows, self-trading between linked wallets, volume spikes without holder growth, and near-identical buy/sell timestamps.",
],
),
(
"Pump and dump patterns",
[
"Pump and dump groups coordinate buying to inflate prices, then dump on new buyers. Look for: Telegram/Discord 'signal' groups, rapid price spikes followed by 90%+ drops, and concentrated holder distribution.",
],
),
(
"MEV sandwich attack explained",
[
"Sandwich attacks insert transactions before and after a victim's trade. The attacker buys before the victim (raising price) and sells after (profiting from slippage). Common on Uniswap, PancakeSwap.",
],
),
(
"Smart contract vulnerability patterns",
[
"Common vulnerabilities: reentrancy (DAO hack), integer overflow, unchecked external calls, tx.origin authentication, and delegatecall to untrusted contracts. Use Slither or Mythril for analysis.",
],
),
(
"Token impersonation scams",
[
"Impersonation tokens copy legitimate token names, symbols, and logos. They trick users into buying the wrong token. Check: contract address matches official sources, token age, and holder count vs. legitimate token.",
],
),
(
"Liquidity pool manipulation",
[
"LP manipulation involves adding/removing liquidity to manipulate price. Signs: sudden LP changes, imbalanced pools, and flash loan-funded attacks. Monitor LP health ratio.",
],
),
]
# Hard negative candidates (similar-sounding but different concepts)
HARD_NEGATIVES = [
"Legitimate token launches with locked liquidity and audited contracts are safe.",
"Airdrops to active community members are a normal marketing strategy.",
"Liquidity mining programs offer yield in exchange for providing LP tokens.",
"Staking rewards are distributed to token holders as an incentive.",
"Governance tokens allow holders to vote on protocol changes.",
"Token burns reduce supply and can increase value for remaining holders.",
"Yield farming involves moving assets between protocols for optimal returns.",
"Bridge protocols enable cross-chain asset transfers.",
"Lending protocols allow users to borrow against their crypto collateral.",
"Market making provides liquidity to exchanges for a spread.",
]
async def mine_hard_negatives(
query: str,
num_negatives: int = 5,
) -> list[str]:
"""
Mine hard negatives from RAG search - documents that are similar
to positives but not relevant. These are the most valuable for training.
"""
negatives = []
# Try RAG search for false positives
try:
from app.rag_service import three_pillar_search
results = await three_pillar_search(
query=query,
collections=["known_scams", "token_analysis", "market_intel"],
limit=num_negatives * 2,
)
# Take results with lower confidence as hard negatives
for r in results.get("results", [])[:num_negatives]:
content = r.get("content", "")
if content and len(content) > 50:
negatives.append(content[:500])
except Exception:
pass
# Fall back to static hard negatives
while len(negatives) < num_negatives:
neg = random.choice(HARD_NEGATIVES)
if neg not in negatives:
negatives.append(neg)
return negatives[:num_negatives]
async def build_training_data() -> list[tuple[str, str, str, float]]:
"""
Build training triplets: (query, positive_doc, negative_doc, teacher_score)
Returns list of triplets with cross-encoder teacher scores.
"""
training_data = []
# Get cross-encoder teacher
try:
from app.cross_encoder_reranker import get_reranker
reranker = await get_reranker()
if not reranker._model_loaded:
await reranker.warm_up()
except Exception:
reranker = None
for query, positives in SCAM_QUERIES:
pos_doc = positives[0]
# Mine hard negatives
negatives = await mine_hard_negatives(query)
for neg_doc in negatives:
# Teacher score: cross-encoder rates (query, positive) and (query, negative)
if reranker and reranker._model_loaded:
try:
pos_score = float(reranker._model.predict([(query, pos_doc)]).tolist()[0])
neg_score = float(reranker._model.predict([(query, neg_doc)]).tolist()[0])
# Margin: how much better is the positive?
margin = pos_score - neg_score
except Exception:
margin = random.uniform(1.5, 3.0)
else:
margin = random.uniform(1.5, 3.0)
training_data.append((query, pos_doc, neg_doc, margin))
return training_data
def train_margin_mse(
model,
training_data: list[tuple[str, str, str, float]],
epochs: int = 3,
batch_size: int = 16,
learning_rate: float = 2e-5,
) -> dict[str, Any]: # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue
"""
Train embeddings using MarginMSE loss.
For each triplet (query, pos, neg):
- Embed query, positive, negative separately
- Compute cosine similarities: sim(q,pos) and sim(q,neg)
- Loss = MSE(sim(q,pos) - sim(q,neg), target_margin)
- Optimize to push pos closer, neg farther
This is the core of domain-specific embedding fine-tuning.
"""
from sentence_transformers import InputExample, losses
from torch.utils.data import DataLoader
# Convert to sentence-transformers InputExample format
examples = []
for query, pos_doc, neg_doc, margin in training_data:
# Normalize margin to 0-1 for cosine distance
target_sim = max(0.0, min(1.0, (margin + 2.0) / 4.0))
examples.append(
InputExample(
texts=[query, pos_doc, neg_doc],
label=target_sim,
)
)
random.shuffle(examples)
# Use MarginMSE loss
train_dataloader = DataLoader(examples, batch_size=batch_size, shuffle=True)
train_loss = losses.MarginMSELoss(model)
logger.info(f"Training on {len(examples)} triplets for {epochs} epochs...")
# Warm-up steps
warmup_steps = int(len(train_dataloader) * epochs * 0.1)
model.fit(
train_objectives=[(train_dataloader, train_loss)],
epochs=epochs,
warmup_steps=warmup_steps,
output_path="/app/data/models/scam-embeddings-v1",
show_progress_bar=True,
)
return {
"status": "completed",
"triplets": len(examples),
"epochs": epochs,
"output": "/app/data/models/scam-embeddings-v1",
"model_size_mb": round(
sum(
os.path.getsize(os.path.join(dp, f))
for dp, _, fs in os.walk("/app/data/models/scam-embeddings-v1")
for f in fs
)
/ (1024 * 1024),
1,
),
}
async def main(epochs: int = 3, batch_size: int = 16):
"""Main training pipeline."""
print("=" * 60)
print(" Domain Fine-Tuned Embeddings - MarginMSE Training")
print("=" * 60)
# 1. Build training data with hard negatives
print("\n[1/3] Building training data with hard negative mining...")
training_data = await build_training_data()
print(f"{len(training_data)} training triplets")
# 2. Load base model
print("\n[2/3] Loading base embedding model...")
try:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-small-en-v1.5")
print(" ✓ BGE-small loaded (384d)")
except Exception as e:
print(f" ✗ Failed to load model: {e}")
return 1
# 3. Train
print(f"\n[3/3] Training MarginMSE for {epochs} epochs...")
result = train_margin_mse(model, training_data, epochs=epochs, batch_size=batch_size)
print(f" ✓ Model saved to {result['output']}")
print(f" Size: {result['model_size_mb']}MB")
return 0
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--epochs", type=int, default=3)
parser.add_argument("--batch-size", type=int, default=16)
args = parser.parse_args()
sys.exit(asyncio.run(main(args.epochs, args.batch_size)))