Explore ML-ready distributions, correlation, and embeddings#

Goal. Explore a bounded, deterministic payment world through computed tables and plots, including a clearly marked simulator-truth view when operational labels are not mature.

Audience. Data scientists and ML engineers exploring FraudTwin’s generated features and model inputs.

Prerequisites. Python 3.12+, FraudTwin, and Polars. Optional plotting cells can install notebook packages.

Source size. 1,000 logical payments with a small fraud scenario enabled for the embedding demonstration; outputs are reproducible and temporary.

Offline path. Analysis runs without Kafka, databases, or cloud services.

Optional notebook packages#

Run this only for richer plots or t-SNE output.

!pip install matplotlib scikit-learn

1. Generate the source world#

import json
from pathlib import Path
from tempfile import TemporaryDirectory

import polars as pl

import fraudtwin
from fraudtwin.config import SimulationRunConfig, load_config

root = next(
    path
    for path in (Path.cwd(), *Path.cwd().parents)
    if (path / "configs" / "minimal.yaml").is_file()
)
base = load_config(root / "configs" / "minimal.yaml")
values = base.model_dump(mode="python")
values["payments"]["daily_target"] = 100
values["simulation"]["duration_days"] = 10
values["population"].update(
    {
        "customers": 100,
        "accounts": 200,
        "cards": 200,
        "merchants": 30,
        "devices": 200,
        "pix_keys": 100,
    }
)
values["behavior"]["amount_max"] = 100
values["simulation"]["seed"] = 2501
values["fraud"]["enabled"] = True
values["fraud"]["target_rate"] = 0.05
config = SimulationRunConfig.model_validate(values)
data = fraudtwin.generate(config)
simulator_truth = {
    record.payment_id: int(record.fraud_truth) for record in data.behavior.fraud_records
}
simulator_fraud_count = sum(simulator_truth.values())
print("Source run")
print(f"  run_id: {data.run_id}")
print(f"  payments: {len(data.behavior.payments):,}")
print(f"  simulator fraud truth: {simulator_fraud_count:,}")
assert len(data.behavior.payments) >= 1_000
Source run
  run_id: RUN-f2bd687be19810c7
  payments: 1,092
  simulator fraud truth: 46

2. Analysis step#

frame = data.require_dataset().frame
print({"rows": frame.height, "columns": frame.width})
assert frame.height > 0
{'rows': 1081, 'columns': 53}

3. Analysis step#

numeric = [
    name
    for name, dtype in frame.schema.items()
    if dtype in (pl.Float64, pl.Float32, pl.Int64, pl.Int32)
]
print({"numeric_features": numeric[:12]})
{'numeric_features': ['amount', 'transaction_count_1m', 'transaction_count_5m', 'transaction_count_1h', 'transaction_count_24h', 'transaction_count_7d', 'transaction_count_30d', 'transaction_amount_1h', 'transaction_amount_24h', 'transaction_amount_7d', 'avg_transaction_amount_30d', 'max_transaction_amount_7d']}

4. Analysis step#

missingness = frame.select([pl.col(name).is_null().mean().alias(name) for name in numeric[:8]])
missingness
shape: (1, 8)
amounttransaction_count_1mtransaction_count_5mtransaction_count_1htransaction_count_24htransaction_count_7dtransaction_count_30dtransaction_amount_1h
f64f64f64f64f64f64f64f64
0.00.00.00.00.00.00.00.0

5. Analysis step#

label_balance = (
    frame.group_by("label").len().sort("len", descending=True)
    if "label" in frame.columns
    else pl.DataFrame()
)
display(label_balance)
shape: (1, 2)
labellen
stru32
null1081

6. Analysis step#

feature_summary = frame.select([pl.col(name).mean().alias(f"{name}_mean") for name in numeric[:6]])
feature_summary
shape: (1, 6)
amount_meantransaction_count_1m_meantransaction_count_5m_meantransaction_count_1h_meantransaction_count_24h_meantransaction_count_7d_mean
f64f64f64f64f64f64
39.8537370.7123030.7187790.797411.8825166.315449

7. Analysis step#

correlation = frame.select(numeric[:6]).corr() if len(numeric) >= 2 else pl.DataFrame()
display(correlation)
shape: (6, 6)
amounttransaction_count_1mtransaction_count_5mtransaction_count_1htransaction_count_24htransaction_count_7d
f64f64f64f64f64f64
1.0-0.099808-0.099875-0.10012-0.094979-0.061106
-0.0998081.00.9996330.9950410.8287380.316516
-0.0998750.9996331.00.9954810.8289930.316903
-0.100120.9950410.9954811.00.8349160.321428
-0.0949790.8287380.8289930.8349161.00.458411
-0.0611060.3165160.3169030.3214280.4584111.0

8. Analysis step#

time_splits = (
    frame.group_by("split").len().sort("split") if "split" in frame.columns else pl.DataFrame()
)
display(time_splits)
shape: (3, 2)
splitlen
stru32
"test"148
"train"794
"validation"139

9. Analysis step#

embedding_input = frame.select(numeric[:6]).with_columns(
    [pl.col(name).cast(pl.Float64).fill_null(0.0).alias(name) for name in numeric[:6]]
)
try:
    from sklearn.manifold import TSNE
    from sklearn.preprocessing import StandardScaler

    values = StandardScaler().fit_transform(embedding_input.to_numpy())
    sample_n = min(240, len(values))
    perplexity = min(30, max(5, sample_n // 10))
    embedding = TSNE(
        n_components=2,
        random_state=42,
        init="pca",
        learning_rate="auto",
        perplexity=perplexity,
        max_iter=350,
    ).fit_transform(values[:sample_n])
    print({"embedding_rows": sample_n, "method": "sklearn.TSNE", "shape": embedding.shape})
    assert embedding.shape == (sample_n, 2)
except ImportError:
    embedding = embedding_input.to_numpy()[: min(240, embedding_input.height)]
    print({"embedding_rows": len(embedding), "method": "scaled-feature fallback"})
{'embedding_rows': 240, 'method': 'sklearn.TSNE', 'shape': (240, 2)}

Optional plot: inspect the embedding#

Run this cell after the install cell to inspect the embedding. Mature operational labels are preferred; this bounded demo falls back to explicitly marked simulator truth because its generated labels are unresolved.

import matplotlib.pyplot as plt

operational_values = (
    frame.get_column("label").to_list()[: len(embedding)]
    if "label" in frame.columns
    else [None] * len(embedding)
)
if any(value is not None for value in operational_values):
    label_values = operational_values
    label_source = "mature operational labels"
elif simulator_fraud_count:
    label_values = [
        simulator_truth.get(payment_id, 0)
        for payment_id in frame["payment_id"].to_list()[: len(embedding)]
    ]
    label_source = "simulator truth (demo only)"
else:
    label_values = [None] * len(embedding)
    label_source = "labels unavailable"
known_positions = [index for index, value in enumerate(label_values) if value is not None]
fig, axis = plt.subplots(figsize=(7, 5))
axis.scatter(
    embedding[:, 0],
    embedding[:, 1],
    color="#cbd5e1",
    alpha=0.55,
    s=28,
    label="unresolved" if known_positions else "points",
)
if known_positions:
    label_codes = [
        1 if str(label_values[index]).lower() in {"1", "true", "fraud"} else 0
        for index in known_positions
    ]
    axis.scatter(
        embedding[known_positions, 0],
        embedding[known_positions, 1],
        c=label_codes,
        cmap="coolwarm",
        alpha=0.85,
        s=36,
        label=f"{label_source} ({len(known_positions):,})",
    )
    if len(set(label_codes)) > 1:
        fig.colorbar(axis.collections[-1], ax=axis, label="label code")
title = (
    f"FraudTwin feature embedding ({label_source})"
    if known_positions
    else "FraudTwin feature embedding (labels unavailable)"
)
axis.set(title=title, xlabel="t-SNE component 1", ylabel="t-SNE component 2")
axis.legend()
fig.tight_layout()
plt.show()
plt.close(fig)
../_images/fb13016b981d9364cf87885f967af6c280ff6039147224d50fabcab1b55a6e0e.png

10. Analysis step#

with TemporaryDirectory(prefix="fraudtwin-viz-27-") as tmp:
    report = Path(tmp) / "feature-summary.json"
    report.write_text(
        json.dumps({"numeric": numeric, "rows": frame.height}, default=str), encoding="utf-8"
    )
    print({"artifact": str(report), "bytes": report.stat().st_size})
    assert report.stat().st_size < 20_000
{'artifact': '/tmp/fraudtwin-viz-27-evt917tv/feature-summary.json', 'bytes': 731}