Data Processing

Data cleaning, wrangling, and feature engineering using Python (Pandas/NumPy) efficiently — vectorized operations, missing data strategies, and encoding techniques for financial datasets.

Key Takeaways

  • apply() and iterrows() are almost always the wrong choice — use vectorized Pandas/NumPy operations.
  • MCAR (Missing Completely At Random), MAR (Missing At Random), MNAR (Missing Not At Random) — the type determines the right imputation strategy.
  • One-hot encoding for low-cardinality categoricals; target encoding for high-cardinality (but watch for leakage).
  • Log-transform right-skewed financial data (income, transaction amounts) before feeding to linear models.
  • pd.merge() default is inner join — explicitly specify how= to avoid silently dropping records.
  • Always sort by date before computing lag/rolling features; never shuffle time-indexed data.

1. Vectorized Operations

Pandas and NumPy operations are implemented in C under the hood. Iterating row-by-row in Python is orders of magnitude slower.

import pandas as pd
import numpy as np

# BAD: apply() is essentially a Python for-loop
df['risk_flag'] = df.apply(lambda row: 1 if row['amount'] > 5000 and row['days_late'] > 30 else 0, axis=1)

# GOOD: vectorized boolean mask — 100x+ faster on large DataFrames
df['risk_flag'] = ((df['amount'] > 5000) & (df['days_late'] > 30)).astype(int)

# GOOD: np.where() for conditional assignment
df['risk_label'] = np.where(df['risk_flag'] == 1, 'High', 'Normal')

# GOOD: vectorized string operation (not apply + lambda)
df['state'] = df['address'].str.extract(r',\s*([A-Z]{2})\s*\d{5}')

Performance Comparison

Method Relative Speed (10M rows) Use When
NumPy / Pandas vectorized 1x (baseline) Almost always — first choice
apply(axis=0) (column-wise) ~5x slower Column-level transformations not in NumPy
apply(axis=1) (row-wise) ~100–500x slower Last resort only
iterrows() ~1000x slower Never — always replace with vectorized

2. Missing Data & Imputation

Types of Missingness

Type Definition Financial Services Example Strategy
MCAR Missingness is truly random — unrelated to any variable System glitch randomly drops income values Mean/median imputation is valid; complete-case analysis is unbiased
MAR Missingness depends on other observed variables, not on the missing value itself Income missing more often for younger applicants Conditional imputation (group-based mean, predictive models)
MNAR Missingness depends on the missing value itself High-income customers refuse to disclose income Model the missingness mechanism; simple imputation introduces bias
import pandas as pd
from sklearn.impute import SimpleImputer, KNNImputer

# Simple imputation
df['income'].fillna(df['income'].median(), inplace=True)

# Group-based imputation (MAR: income missing more for younger applicants)
df['income'] = df.groupby('age_band')['income'].transform(
    lambda x: x.fillna(x.median())
)

# KNN imputation (uses neighboring rows)
imputer = KNNImputer(n_neighbors=5)
df[['income', 'credit_limit']] = imputer.fit_transform(df[['income', 'credit_limit']])

# Add missingness indicator before imputing (preserves MNAR signal)
df['income_missing'] = df['income'].isna().astype(int)
df['income'].fillna(df['income'].median(), inplace=True)

Outlier Treatment

# Winsorization: clip to 1st and 99th percentile
lower = df['amount'].quantile(0.01)
upper = df['amount'].quantile(0.99)
df['amount_clipped'] = df['amount'].clip(lower, upper)

# Log transformation: stabilize right-skewed distributions
df['log_amount'] = np.log1p(df['amount'])   # log(x + 1) handles zeros

# Z-score outlier detection
z_scores = (df['amount'] - df['amount'].mean()) / df['amount'].std()
df['is_outlier'] = (z_scores.abs() > 3).astype(int)

3. Shaping & Merging

import pandas as pd

# GroupBy with multiple aggregations
summary = df.groupby('customer_id').agg(
    total_spend=('amount', 'sum'),
    txn_count=('amount', 'count'),
    avg_txn=('amount', 'mean'),
    last_txn_date=('txn_date', 'max')
).reset_index()

# Merging — always specify how= explicitly
merged = pd.merge(
    customers,
    transactions,
    on='customer_id',
    how='left'    # keep all customers, even those with no transactions
)
print(f"Before merge: {len(customers)} rows")
print(f"After merge:  {len(merged)} rows")  # should equal len(customers) for left join

# Pivot: wide format
pivot = df.pivot_table(
    index='customer_id',
    columns='month',
    values='spend',
    aggfunc='sum',
    fill_value=0
)

# Melt: long format
long_df = pivot.reset_index().melt(
    id_vars='customer_id',
    var_name='month',
    value_name='spend'
)

4. Feature Engineering

Categorical Encoding

Method When to Use Risk
One-Hot Encoding Low cardinality (< 20 categories) Curse of dimensionality at high cardinality
Label Encoding Ordinal variables (e.g., credit tier: Bronze/Silver/Gold) Implies false numeric ordering for nominal vars
Target Encoding High cardinality (zip codes, merchant IDs) Data leakage if not applied within CV folds
Frequency Encoding High cardinality, tree-based models Different categories with same frequency are indistinguishable
import pandas as pd
from sklearn.preprocessing import StandardScaler

# One-hot encoding
df = pd.get_dummies(df, columns=['product_type'], drop_first=True)

# Target encoding with cross-validation (prevent leakage)
from category_encoders import TargetEncoder
encoder = TargetEncoder(cols=['merchant_category'])
# IMPORTANT: fit only on training data, then transform train and test separately
encoder.fit(X_train['merchant_category'], y_train)
X_train['merchant_category_enc'] = encoder.transform(X_train['merchant_category'])
X_test['merchant_category_enc']  = encoder.transform(X_test['merchant_category'])

# Scaling — fit ONLY on train, transform both
scaler = StandardScaler()
X_train[['income', 'balance']] = scaler.fit_transform(X_train[['income', 'balance']])
X_test[['income', 'balance']]  = scaler.transform(X_test[['income', 'balance']])

# Time-series lag features — sort first!
df = df.sort_values('txn_date')
df['spend_lag_1'] = df.groupby('customer_id')['spend'].shift(1)
df['spend_roll_7'] = df.groupby('customer_id')['spend'].transform(
    lambda x: x.rolling(7, min_periods=1).mean()
)

Practice Questions

Q1: What are MCAR, MAR, and MNAR? Give a financial services example of each.
  • MCAR (Missing Completely At Random): Missingness is truly random — unrelated to any variable in the dataset. Example: a database sensor glitch randomly drops income values for 2% of records. Complete-case analysis is unbiased; mean/median imputation is valid.
  • MAR (Missing At Random): Missingness depends on other observed variables but not on the missing value itself. Example: income is missing more often for younger applicants, but conditional on age, it is random. Group-based or model-based imputation is appropriate.
  • MNAR (Missing Not At Random): Missingness depends on the missing value itself. Example: high-income customers refuse to disclose income. Simple imputation introduces bias — the imputed mean will underestimate true income for those customers. You must model the missingness mechanism explicitly, or add a missingness indicator feature.
Q2: You have a transaction amount column with extreme right skew (values from $1 to $500,000). What transformations do you apply, and why?

Log transformation: Apply log(amount + 1) to stabilize variance and reduce skew. The +1 handles zero values. This makes the distribution closer to Normal, which benefits linear models and distance-based models (KNN, SVM) that assume feature distributions are roughly symmetric.

df['log_amount'] = np.log1p(df['amount'])   # log(amount + 1)

Winsorization (alternative): Clip values at the 1st and 99th percentile to reduce outlier impact while preserving more signal than a hard cap:

lower, upper = df['amount'].quantile([0.01, 0.99])
df['amount_clipped'] = df['amount'].clip(lower, upper)

For tree-based models (XGBoost, Random Forest) neither transformation is strictly necessary — trees are invariant to monotonic transformations — but log-transform still helps if the target variable is skewed.

Q3: What is the risk of applying SMOTE before splitting into train/test sets?

Data leakage. SMOTE (Synthetic Minority Over-sampling Technique) generates synthetic minority samples by interpolating between real minority-class neighbors. If applied before splitting:

  1. Synthetic samples are created based on the full dataset, including real test-set observations.
  2. The synthetic training samples may be near-interpolations of real test samples — the model has indirectly "seen" the test set during training.
  3. Evaluation metrics will be artificially inflated.

Correct order: (1) Split into train/test. (2) Apply SMOTE to the training set only. (3) Evaluate on the untouched test set. Same principle applies to all resampling — oversampling, undersampling, and imputation.

Q4: Your colleague uses df.apply(some_function, axis=1) on a 10M-row DataFrame. What do you say?

apply(axis=1) iterates row-by-row in pure Python — it is essentially a for-loop with overhead. On 10M rows this is extremely slow (potentially minutes vs. seconds for vectorized equivalents).

Ask: can this operation be rewritten as a vectorized expression? Most operations can:

# Instead of apply — use vectorized alternatives:
# Conditional: np.where()
df['flag'] = np.where(df['amount'] > 5000, 1, 0)

# Multiple conditions: np.select()
conditions = [df['score'] >= 700, df['score'] >= 600]
choices    = ['Good', 'Fair']
df['rating'] = np.select(conditions, choices, default='Poor')

# String operations: .str methods
df['city'] = df['address'].str.split(',').str[0].str.strip()

# Math: native arithmetic
df['ratio'] = df['balance'] / df['credit_limit'].replace(0, np.nan)

Only use apply() as a true last resort when the operation cannot be expressed as NumPy/Pandas vectorized operations — for example, calling a third-party API per row or applying a complex stateful function.

Action Items

  • Write a script that standardizes messy date strings (multiple formats) into a unified datetime object using pd.to_datetime().
  • Practice handling a DataFrame with 20% random missing values using both median imputation and group-based mean imputation.
  • Replace an apply(axis=1) call in a notebook with a vectorized NumPy equivalent and benchmark the difference.
  • Use pd.melt() to unpivot a wide dataset and pivot_table() to reshape it back.
  • Implement target encoding for a high-cardinality column correctly — fit encoder only on training data, transform both train and test separately.