| |
| """ |
| 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__) |
|
|
| |
|
|
| 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_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: |
| 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, |
| ) |
| |
| 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 |
|
|
| |
| 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 = [] |
|
|
| |
| 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] |
|
|
| |
| negatives = await mine_hard_negatives(query) |
|
|
| for neg_doc in negatives: |
| |
| 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 = 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]: |
| """ |
| 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 |
|
|
| |
| examples = [] |
| for query, pos_doc, neg_doc, margin in training_data: |
| |
| 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) |
|
|
| |
| 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...") |
|
|
| |
| 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) |
|
|
| |
| print("\n[1/3] Building training data with hard negative mining...") |
| training_data = await build_training_data() |
| print(f" ✓ {len(training_data)} training triplets") |
|
|
| |
| 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 |
|
|
| |
| 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))) |
|
|