File size: 10,536 Bytes
6993919
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
#!/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]:
    """
    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)))