From Events to a Trustworthy ML Dataset#
You are asked to build a fraud detector after a suspicious payment incident. The simulator knows what happened, but events arrive imperfectly and labels become available later. Your task is to build a dataset using only information that was available at prediction time.
1. Start with a fraud-enabled run#
We use a versioned benchmark configuration so the investigation can be repeated exactly.
from pathlib import Path
import polars as pl
import fraudtwin
from fraudtwin.config import load_config
project_root = next(
path
for path in (Path.cwd(), *Path.cwd().parents)
if (path / "configs" / "minimal.yaml").is_file()
)
config_path = project_root / "configs" / "benchmarks" / "difficulty-v1.yaml"
config = load_config(config_path)
# Keep the fraud scenarios, but introduce realistic data-quality issues.
config = config.model_copy(
update={"quality": config.quality.model_copy(update={"profile": "realistic"})}
)
data = fraudtwin.generate(config)
print("Run:", data.run_id)
Run: RUN-0b12923ed7256443
2. The incident begins with events#
Payment events are operational records. They include event time and source availability, which are not always identical.
events = pl.DataFrame(
[
{
"Event": event.event_id,
"Type": event.event_type,
"Payment": event.payment_id,
"Event time": event.event_time,
"Available at": event.source_available_at,
}
for event in data.behavior.payment_events[:8]
]
)
events
| Event | Type | Payment | Event time | Available at |
|---|---|---|---|---|
| str | str | str | datetime[μs, UTC] | datetime[μs, UTC] |
| "EVT-00000001" | "TRANSFER_COMPLETED" | "PAY-00000001" | 2026-01-02 12:43:00 UTC | 2026-01-02 12:43:05 UTC |
| "EVT-00000002" | "CARD_PAYMENT_INITIATED" | "PAY-00000002" | 2026-01-01 19:53:00 UTC | 2026-01-01 19:53:05 UTC |
| "EVT-00000002-02" | "CARD_AUTHORIZATION_REQUESTED" | "PAY-00000002" | 2026-01-01 19:53:01 UTC | 2026-01-01 19:53:06 UTC |
| "EVT-00000002-03" | "CARD_AUTHORIZED" | "PAY-00000002" | 2026-01-01 19:53:02 UTC | 2026-01-01 19:53:07 UTC |
| "EVT-00000002-04" | "CARD_CAPTURED" | "PAY-00000002" | 2026-01-01 19:53:07 UTC | 2026-01-01 19:53:12 UTC |
| "EVT-00000002-05" | "CARD_CLEARED" | "PAY-00000002" | 2026-01-01 19:53:37 UTC | 2026-01-01 19:53:42 UTC |
| "EVT-00000002-06" | "CARD_SETTLED" | "PAY-00000002" | 2026-01-01 19:54:37 UTC | 2026-01-01 19:54:42 UTC |
| "EVT-00000003" | "PIX_INITIATED" | "PAY-00000003" | 2026-01-01 06:50:00 UTC | 2026-01-01 06:50:02 UTC |
3. The stream is imperfect#
Real pipelines contain duplicates, missing values, late events, and out-of-order records. FraudTwin records these faults instead of hiding them.
quality = pl.DataFrame(
{"Issue": name, "Rows": count}
for name, count in data.manifest.quality_fault_counts.items()
if count > 0
)
quality.sort("Rows", descending=True)
| Issue | Rows |
|---|---|
| str | i64 |
| "late_events" | 42 |
| "out_of_order_events" | 16 |
| "missing_optional_fields" | 14 |
| "duplicate_events" | 3 |
| "negative_amounts" | 2 |
| "duplicate_records" | 1 |
4. Separate fraud truth from lookalikes#
The simulator’s truth table contains both real fraud and hard negatives. A detector must learn to distinguish them rather than rely on one obvious rule.
truth = (
pl.DataFrame({"Record type": [record.record_type for record in data.behavior.fraud_records]})
.group_by("Record type")
.len()
.rename({"len": "Records"})
)
truth
| Record type | Records |
|---|---|
| str | u32 |
| "HARD_NEGATIVE" | 9 |
| "FRAUD" | 25 |
5. Observe the investigation workflow#
Alerts, cases, confirmations, and labels are produced progressively. The counts show how the incident becomes observable to downstream systems.
workflow = pl.DataFrame(
{
"Artifact": ["Fraud records", "Alerts", "Cases", "Confirmations", "Labels"],
"Rows": [
len(data.behavior.fraud_records),
len(data.behavior.alerts),
len(data.behavior.fraud_cases),
len(data.behavior.case_confirmations),
len(data.behavior.fraud_labels),
],
}
)
workflow
| Artifact | Rows |
|---|---|
| str | i64 |
| "Fraud records" | 34 |
| "Alerts" | 34 |
| "Cases" | 34 |
| "Confirmations" | 34 |
| "Labels" | 34 |
6. Labels arrive after the payment#
A label’s availability time is later than the time of the fraud itself. This delay is central to realistic fraud-model validation.
labels = pl.DataFrame(
[
{
"Payment": label.payment_id,
"Label": label.label,
"Fraud occurred": label.fraud_occurred_at,
"Label available": label.label_available_at,
}
for label in data.behavior.fraud_labels[:5]
]
)
labels
| Payment | Label | Fraud occurred | Label available |
|---|---|---|---|
| str | str | datetime[μs, UTC] | datetime[μs, UTC] |
| "PAY-F01-000001-000001" | "FRAUD" | 2026-01-01 00:00:00 UTC | 2026-01-03 02:06:11 UTC |
| "PAY-F01-000001-000002" | "FRAUD" | 2026-01-01 00:00:02 UTC | 2026-01-03 01:06:13 UTC |
| "PAY-F01-000001-000003" | "FRAUD" | 2026-01-01 00:00:03 UTC | 2026-01-03 01:06:14 UTC |
| "PAY-HN-F01-000001-000001" | "LEGITIMATE" | 2026-01-01 00:00:00 UTC | 2026-01-02 01:06:07 UTC |
| "PAY-HN-F01-000004-000001" | "LEGITIMATE" | 2026-01-01 00:00:00 UTC | 2026-01-02 01:06:07 UTC |
7. Build the point-in-time dataset#
FraudTwin builds each row using features and labels that were available at that row’s prediction time. This example keeps unresolved labels, so a null label means the outcome was not available yet at prediction_time; it is not a missing payment field.
ml_dataset = data.require_dataset().frame
print("Rows:", ml_dataset.height)
print("Columns:", ml_dataset.width)
print(
{
"labelled": ml_dataset.filter(pl.col("label").is_not_null()).height,
"unresolved": ml_dataset.filter(pl.col("label").is_null()).height,
}
)
preview = pl.concat(
[
ml_dataset.filter(pl.col("label").is_not_null()).head(3),
ml_dataset.filter(pl.col("label").is_null()).head(2),
],
how="vertical",
)
preview.select(["payment_id", "prediction_time", "label_available_at", "label", "split"])
Rows: 288
Columns: 53
{'labelled': 0, 'unresolved': 288}
| payment_id | prediction_time | label_available_at | label | split |
|---|---|---|---|---|
| str | datetime[μs, UTC] | datetime[μs, UTC] | str | str |
| "PAY-00000001" | 2026-01-02 12:43:05 UTC | null | null | "validation" |
| "PAY-00000002" | 2026-01-01 19:53:05 UTC | null | null | "train" |
8. Inspect the temporal splits#
The dataset uses time-aware splits so validation and test records represent future activity relative to training.
ml_dataset.group_by("split").len().rename({"len": "Rows"}).sort("split")
| split | Rows |
|---|---|
| str | u32 |
| "test" | 37 |
| "train" | 225 |
| "validation" | 26 |
9. Finish the investigation#
The generated run, quality diagnostics, fraud truth, delayed workflow records, and ML dataset are all tied together by stable IDs and a manifest. That makes the experiment reproducible and auditable.
Record the generated shape and tutorial contract.#
summary = {
"payments": len(data.behavior.payments),
"events": len(data.behavior.payment_events),
}
print(summary)
assert summary["payments"] >= 0
{'payments': 301, 'events': 1443}
Record the generated shape and tutorial contract.#
summary = {
"payments": len(data.behavior.payments),
"events": len(data.behavior.payment_events),
}
print(summary)
assert summary["payments"] >= 0
{'payments': 301, 'events': 1443}