File size: 5,959 Bytes
dda22ae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Production data loading and validation module.

Handles reading transaction data from multiple sources (Parquet, CSV, Delta Lake)
with schema validation, data quality checks, and logging.
"""

import logging
from pathlib import Path
from typing import Optional

import numpy as np
import pandas as pd

logger = logging.getLogger(__name__)

EXPECTED_SCHEMA = {
    "transaction_id": "object",
    "timestamp": "datetime64[ns]",
    "cardholder_id": "object",
    "card_type": "object",
    "merchant_id": "object",
    "merchant_category_code": "object",
    "merchant_risk_score": "float64",
    "transaction_amount": "float64",
    "entry_mode": "object",
    "country_code": "object",
    "is_fraud": "int64",
}

REQUIRED_COLUMNS = list(EXPECTED_SCHEMA.keys())


class DataValidationError(Exception):
    """Raised when data fails validation checks."""


class TransactionDataLoader:
    """Production-grade data loader with validation and quality checks."""

    def __init__(self, data_path: str | Path):
        self.data_path = Path(data_path)
        if not self.data_path.exists():
            raise FileNotFoundError(f"Data file not found: {self.data_path}")

    def load(self, validate: bool = True, sample_frac: Optional[float] = None) -> pd.DataFrame:
        """Load transaction data from file.

        Args:
            validate: Whether to run schema and quality validation.
            sample_frac: Optional fraction to sample for development/testing.

        Returns:
            DataFrame with validated transaction data.
        """
        logger.info("Loading data from %s", self.data_path)

        if self.data_path.suffix == ".parquet":
            df = pd.read_parquet(self.data_path)
        elif self.data_path.suffix == ".csv":
            df = pd.read_csv(self.data_path, parse_dates=["timestamp"])
        else:
            raise ValueError(f"Unsupported file format: {self.data_path.suffix}")

        logger.info("Loaded %d rows, %d columns", len(df), len(df.columns))

        if sample_frac is not None:
            original_len = len(df)
            df = df.sample(frac=sample_frac, random_state=42).reset_index(drop=True)
            logger.info("Sampled %.1f%% β€” %d β†’ %d rows", sample_frac * 100, original_len, len(df))

        if validate:
            self._validate_schema(df)
            self._validate_quality(df)

        return df

    def _validate_schema(self, df: pd.DataFrame) -> None:
        """Check that all required columns are present."""
        missing = set(REQUIRED_COLUMNS) - set(df.columns)
        if missing:
            raise DataValidationError(f"Missing required columns: {missing}")
        logger.info("Schema validation passed β€” all %d required columns present", len(REQUIRED_COLUMNS))

    def _validate_quality(self, df: pd.DataFrame) -> None:
        """Run data quality checks and log warnings."""
        issues = []

        # Check for nulls in critical columns
        critical_cols = ["transaction_id", "cardholder_id", "transaction_amount", "timestamp"]
        for col in critical_cols:
            null_count = df[col].isna().sum()
            if null_count > 0:
                issues.append(f"Column '{col}' has {null_count} null values")

        # Check for duplicate transaction IDs
        dup_count = df["transaction_id"].duplicated().sum()
        if dup_count > 0:
            issues.append(f"{dup_count} duplicate transaction IDs found")

        # Check for negative amounts
        neg_count = (df["transaction_amount"] < 0).sum()
        if neg_count > 0:
            issues.append(f"{neg_count} transactions with negative amounts")

        # Check fraud rate is reasonable (0.1% - 10%)
        fraud_rate = df["is_fraud"].mean()
        if fraud_rate < 0.001 or fraud_rate > 0.10:
            issues.append(f"Unusual fraud rate: {fraud_rate:.4%}")

        # Check timestamp range
        ts_range = (df["timestamp"].max() - df["timestamp"].min()).days
        if ts_range < 1:
            issues.append(f"Very narrow timestamp range: {ts_range} days")

        if issues:
            for issue in issues:
                logger.warning("Data quality issue: %s", issue)
        else:
            logger.info("Data quality validation passed")


def load_and_split(
    data_path: str | Path,
    test_size: float = 0.2,
    validation_size: float = 0.1,
    random_state: int = 42,
    stratify: bool = True,
) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
    """Load data and split into train/validation/test sets.

    Uses time-based splitting for production realism β€” train on older data,
    test on newer data to prevent data leakage.

    Args:
        data_path: Path to the transaction data file.
        test_size: Fraction of data for the test set.
        validation_size: Fraction of data for the validation set.
        random_state: Random seed.
        stratify: Whether to use time-based stratification.

    Returns:
        Tuple of (train_df, val_df, test_df).
    """
    loader = TransactionDataLoader(data_path)
    df = loader.load(validate=True)

    # Time-based split to prevent data leakage
    df = df.sort_values("timestamp").reset_index(drop=True)
    n = len(df)

    train_end = int(n * (1 - test_size - validation_size))
    val_end = int(n * (1 - test_size))

    train_df = df.iloc[:train_end].copy()
    val_df = df.iloc[train_end:val_end].copy()
    test_df = df.iloc[val_end:].copy()

    logger.info(
        "Data split β€” Train: %d (%.1f%%), Val: %d (%.1f%%), Test: %d (%.1f%%)",
        len(train_df), len(train_df) / n * 100,
        len(val_df), len(val_df) / n * 100,
        len(test_df), len(test_df) / n * 100,
    )

    # Log fraud rates per split
    for name, split_df in [("Train", train_df), ("Val", val_df), ("Test", test_df)]:
        fraud_rate = split_df["is_fraud"].mean()
        logger.info("  %s fraud rate: %.4f%%", name, fraud_rate * 100)

    return train_df, val_df, test_df