|
11 | 11 | import numpy as np |
12 | 12 |
|
13 | 13 | from gen_fraud_graph.embeddings import EmbeddingGenerator |
14 | | -from gen_fraud_graph.exporters import get_headers, write_output |
| 14 | +from gen_fraud_graph.exporters import append_csv, get_headers, write_output |
15 | 15 |
|
16 | 16 | # --------------------------------------------------------------------------- |
17 | 17 | # Suspicious transaction descriptions used across typologies |
|
28 | 28 | "high-value cross-border wire", |
29 | 29 | ] |
30 | 30 |
|
| 31 | +#Description specififc to structuring/smurfing patterns. |
| 32 | +STRUCTURING_DESCRIPTIONS: list[str] = [ |
| 33 | + "cash deposit below reporting threshold", |
| 34 | + "multiple small deposits same day", |
| 35 | + "structured payment just under limit", |
| 36 | + "smurfing deposit via branch teller", |
| 37 | + "incremental cash deposit sub-threshold", |
| 38 | + "repeated near limit ATM deposit", |
| 39 | + "fragmented transfer to evade detection", |
| 40 | +] |
31 | 41 |
|
32 | 42 | # --------------------------------------------------------------------------- |
33 | 43 | # Fraud ring generator (cyclic money-laundering patterns) |
@@ -142,3 +152,148 @@ def generate( |
142 | 152 | write_output(file_cases, headers_cases, case_rows, compress=compress) |
143 | 153 |
|
144 | 154 | return len(tx_rows), current_tx_id |
| 155 | + |
| 156 | +@dataclass |
| 157 | +class StructuringGenerator: |
| 158 | + """Generate structuring (smurfing) fraud patterns. |
| 159 | + In a structuring scheme a single coordinator account receives funds from |
| 160 | + several "smurf" accounts, each sending amounts just below the BSA/FinCEN |
| 161 | + Cash Transaction Report (CTR) threshold of $10,000.00. The coordinator |
| 162 | + aggregates these deposits to move a larger sum without triggering a single |
| 163 | + reportable event. |
| 164 | +
|
| 165 | + Graph shape:: |
| 166 | + |
| 167 | + smurf_0 -> coordinator |
| 168 | + smurf_1 -> coordinator |
| 169 | + ... |
| 170 | + smurf_N -> coordinator |
| 171 | + Multiple sources converge on one node. |
| 172 | + This is a structurally distinct from the cyclic ring produced by |
| 173 | + :class:'FraudRingGenerator' and exercises different subgraph-detection |
| 174 | + algorithms. |
| 175 | + |
| 176 | + Args: |
| 177 | + num_patterns: How many structuring patterns to create. |
| 178 | + smurfs_range: ''(min_smurfs, mac_smurfs)'' - number of feeder |
| 179 | + accounts per pattern. Mirrors the real world practice of using |
| 180 | + 3-10 smurfs to stay inconspicuous. |
| 181 | + amount_range: ''(min_amount, max_amount)'' - each smurf transfer is |
| 182 | + drawn uniformly form this range. Defaults to $8_000-$9_900, |
| 183 | + deliberately sub-threshold. |
| 184 | + """ |
| 185 | + |
| 186 | + |
| 187 | + num_patterns: int = 100 |
| 188 | + smurfs_range: tuple[int, int] = (3, 10) |
| 189 | + amount_range: tuple[float, float] = (8_000.00, 9_900.00) |
| 190 | + _descriptions: list[str] = field(default_factory=lambda: STRUCTURING_DESCRIPTIONS) |
| 191 | + |
| 192 | + def generate( |
| 193 | + self, |
| 194 | + max_account_id: int, |
| 195 | + start_tx_id: int, |
| 196 | + embedder: EmbeddingGenerator, |
| 197 | + output_dir: str, |
| 198 | + fmt: str = "csv", |
| 199 | + compress: bool = False, |
| 200 | + ) -> tuple[int, int]: |
| 201 | + """Generate structuring patterns and append to fraud output files. |
| 202 | + |
| 203 | + Output files are appended to the same ``fraud/`` directory used by |
| 204 | + :class:`FraudRingGenerator` so a single pipeline run can inject both |
| 205 | + typologies into one dataset. |
| 206 | + |
| 207 | + Args: |
| 208 | + max_account_id: Upper bound of account IDs already generated. |
| 209 | + start_tx_id: First transaction ID to use (must not collide with |
| 210 | + IDs already written by the ring generator or normal txs). |
| 211 | + embedder: Embedding generator instance — same one used by the |
| 212 | + ring generator so embedding provenance is consistent. |
| 213 | + output_dir: Root output directory. |
| 214 | + fmt: ``"csv"`` or ``"neptune"``. |
| 215 | + compress: ZIP the output CSV files. |
| 216 | + |
| 217 | + Returns: |
| 218 | + ``(num_fraud_transactions, next_tx_id)`` |
| 219 | + """ |
| 220 | + import os |
| 221 | + |
| 222 | + from tqdm import tqdm |
| 223 | + |
| 224 | + fraud_dir = os.path.join(output_dir, "fraud") |
| 225 | + os.makedirs(fraud_dir, exist_ok=True) |
| 226 | + |
| 227 | + headers_tx = get_headers("transaction", fmt) # type: ignore[arg-type] |
| 228 | + headers_cases = [ |
| 229 | + "pattern_id", |
| 230 | + "start_acc_id", |
| 231 | + "pattern_type", |
| 232 | + "depth", |
| 233 | + "involved_accounts", |
| 234 | + ] |
| 235 | + |
| 236 | + tx_rows: list[list] = [] |
| 237 | + case_rows: list[list] = [] |
| 238 | + current_tx_id = start_tx_id |
| 239 | + |
| 240 | + for pattern_id in tqdm(range(self.num_patterns), desc="Generating structuring patterns"): |
| 241 | + min_s, max_s = self.smurfs_range |
| 242 | + num_smurfs = random.randint(min_s, max_s) |
| 243 | + |
| 244 | + # The coordinator sits at a random offset; smurfs occupy the |
| 245 | + # num_smurfs slots immediately after it. We need num_smurfs + 1 |
| 246 | + # consecutive IDs so we guard against tiny account pools. |
| 247 | + needed = num_smurfs + 1 |
| 248 | + if max_account_id < needed: |
| 249 | + coordinator_idx = 0 |
| 250 | + else: |
| 251 | + coordinator_idx = random.randint(0, max_account_id - needed) |
| 252 | + |
| 253 | + coordinator = f"acc_{coordinator_idx}" |
| 254 | + smurfs = [f"acc_{coordinator_idx + 1 + i}" for i in range(num_smurfs)] |
| 255 | + involved = "|".join([coordinator] + smurfs) |
| 256 | + |
| 257 | + batch_texts: list[str] = [] |
| 258 | + batch_rows: list[list] = [] |
| 259 | + |
| 260 | + for smurf in smurfs: |
| 261 | + amount = round(random.uniform(*self.amount_range), 2) |
| 262 | + desc = random.choice(self._descriptions) |
| 263 | + batch_texts.append(desc) |
| 264 | + |
| 265 | + row: list = [f"tx_{current_tx_id}", smurf, coordinator] |
| 266 | + if fmt == "neptune": |
| 267 | + row.append("TRANSFER") |
| 268 | + row.extend([amount, "2024-01-01T12:00:00", desc]) |
| 269 | + batch_rows.append(row) |
| 270 | + current_tx_id += 1 |
| 271 | + |
| 272 | + embeddings = embedder.generate(batch_texts) |
| 273 | + |
| 274 | + for idx, r in enumerate(batch_rows): |
| 275 | + if fmt == "neptune": |
| 276 | + tx_rows.append(r) |
| 277 | + else: |
| 278 | + vec = embeddings[idx] |
| 279 | + if isinstance(vec, np.ndarray): |
| 280 | + vec = vec.tolist() |
| 281 | + tx_rows.append(r + ["|".join(map(str, vec))]) |
| 282 | + |
| 283 | + case_rows.append( |
| 284 | + [ |
| 285 | + f"struct_{pattern_id}", |
| 286 | + coordinator, |
| 287 | + "structuring", |
| 288 | + num_smurfs, # depth = number of feeder hops |
| 289 | + involved, |
| 290 | + ] |
| 291 | + ) |
| 292 | + |
| 293 | + # Append to the same fraud files so both typologies land in one CSV. |
| 294 | + file_tx = os.path.join(fraud_dir, "transactions_fraud") |
| 295 | + file_cases = os.path.join(fraud_dir, "fraud_cases") |
| 296 | + append_csv(file_tx + ".csv", headers_tx, tx_rows) |
| 297 | + append_csv(file_cases + ".csv", headers_cases, case_rows) |
| 298 | + |
| 299 | + return len(tx_rows), current_tx_id |
0 commit comments