"DataFrames for SQL Minds" — pandas in Practice
pandas explained through SQL. Series, DataFrames and the Index; selection with loc and iloc; filtering; groupby as GROUP BY and transform as a window function; merge as JOIN; time series and rolling windows; missing data; Copy-on-Write; and building a fraud feature table.
Story Opening
Arjun’s seven parallel NumPy arrays went into the bin. In their place, Priya wrote a single object:
import pandas as pd
txns = pd.DataFrame({ "txn_id": ["T1", "T2", "T3", "T4"], "merchant": ["AcmeMart", "ZipFuel", "AcmeMart", "BookNook"], "amount": [120.0, 45.5, 3000.0, 80.0],})
print(txns.groupby("merchant")["amount"].sum().to_dict())# -> {'AcmeMart': 3120.0, 'BookNook': 80.0, 'ZipFuel': 45.5}“It’s a table,” she said. “Columns have names and types. Rows have labels. You can filter, group, join and window it. Every column is a NumPy array underneath, so it’s fast — as long as you keep thinking in columns, not rows.”
Arjun had written SQL for fifteen years. That, it turned out, was the best possible preparation for pandas.
SQL → pandas: The Rosetta Stone
| SQL | pandas |
|---|---|
SELECT a, b FROM t | t[["a", "b"]] |
WHERE amount > 100 | t[t["amount"] > 100] or t.query("amount > 100") |
WHERE m IN ('A', 'B') | t[t["m"].isin(["A", "B"])] |
ORDER BY amount DESC LIMIT 5 | t.nlargest(5, "amount") / t.sort_values("amount", ascending=False).head(5) |
SELECT m, SUM(amount) ... GROUP BY m | t.groupby("m")["amount"].sum() |
HAVING COUNT(*) > 2 | .filter(lambda g: len(g) > 2) or filter the aggregated result |
SUM(x) OVER (PARTITION BY c) | t.groupby("c")["x"].transform("sum") |
ROW_NUMBER() OVER (PARTITION BY c ORDER BY ts) | t.sort_values("ts").groupby("c").cumcount() + 1 |
LEFT JOIN u ON t.k = u.k | t.merge(u, on="k", how="left") |
UNION ALL | pd.concat([t1, t2]) |
CASE WHEN ... END | np.where(...), np.select(...), pd.cut(...) |
COALESCE(x, 0) | t["x"].fillna(0) |
DISTINCT | t.drop_duplicates() / t["m"].unique() |
The Sample Data
Every block in this part rebuilds the same small transaction table so you can run it on its own. It’s eight rows on purpose: small enough to verify by eye.
import pandas as pd
txns = pd.DataFrame({ "txn_id": ["T1", "T2", "T3", "T4", "T5", "T6", "T7", "T8"], "customer_id": ["C1", "C2", "C1", "C3", "C2", "C1", "C3", "C2"], "merchant": ["AcmeMart", "ZipFuel", "AcmeMart", "BookNook", "AcmeMart", "ZipFuel", "BookNook", "ZipFuel"], "amount": [120.0, 45.5, 3000.0, 80.0, 15000.0, 60.0, 95.0, 22.0], "ts": pd.to_datetime([ "2026-09-01 09:15", "2026-09-01 10:02", "2026-09-01 23:40", "2026-09-02 08:30", "2026-09-02 02:11", "2026-09-02 12:00", "2026-09-03 18:45", "2026-09-03 19:05", ]), "is_fraud": [0, 0, 1, 0, 1, 0, 0, 0],})
print(txns.shape) # -> (8, 6)print(txns.head(3))# txn_id customer_id merchant amount ts is_fraud# 0 T1 C1 AcmeMart 120.0 2026-09-01 09:15:00 0# 1 T2 C2 ZipFuel 45.5 2026-09-01 10:02:00 0# 2 T3 C1 AcmeMart 3000.0 2026-09-01 23:40:00 1In real life the first line is usually txns = pd.read_csv("txns.csv", parse_dates=["ts"]) or pd.read_parquet(...).
Series, DataFrame and the Index
- A
Seriesis a 1-D labelled array: a NumPy array plus an index of labels. - A
DataFrameis a dict of Series sharing one index: a table whose columns may have different dtypes. - The
Indexlabels the rows. By default it’s0..n-1(aRangeIndex), but it can be anything — IDs, timestamps.
import pandas as pd
amounts = pd.Series([120.0, 45.5, 3000.0], index=["T1", "T2", "T3"], name="amount")
print(amounts["T2"]) # -> 45.5 (by label)print(amounts.iloc[0]) # -> 120.0 (by position)print(amounts.values.dtype) # -> float64 (a NumPy array underneath)print((amounts * 2).tolist()) # -> [240.0, 91.0, 6000.0] (vectorised, like NumPy)print(amounts[amounts > 100].index.tolist()) # -> ['T1', 'T3']Inspecting a DataFrame
import pandas as pd
txns = pd.DataFrame({ "merchant": ["AcmeMart", "ZipFuel", "AcmeMart", "BookNook"], "amount": [120.0, 45.5, 3000.0, 80.0], "is_fraud": [0, 0, 1, 0],})
print(txns.dtypes.astype(str).to_dict()) # -> {'merchant': 'str', 'amount': 'float64', 'is_fraud': 'int64'}print(txns.columns.tolist()) # -> ['merchant', 'amount', 'is_fraud']print(txns["merchant"].value_counts().to_dict()) # -> {'AcmeMart': 2, 'ZipFuel': 1, 'BookNook': 1}print(txns["amount"].describe().round(1).to_dict())# -> {'count': 4.0, 'mean': 811.4, 'std': 1459.4, 'min': 45.5, '25%': 71.4, '50%': 100.0, '75%': 840.0, 'max': 3000.0}txns.info() # column dtypes, non-null counts, memory usageTip — Your first three commands on any new dataset:
df.info()(types and nulls),df.describe()(distributions),df.head()(eyeball it). Most data bugs are visible right there.
Selecting Data: [], loc and iloc
import pandas as pd
txns = pd.DataFrame({ "txn_id": ["T1", "T2", "T3", "T4"], "merchant": ["AcmeMart", "ZipFuel", "AcmeMart", "BookNook"], "amount": [120.0, 45.5, 3000.0, 80.0],}).set_index("txn_id") # use txn_id as the row labels
# Columnsprint(type(txns["amount"]).__name__) # -> Series (single column)print(type(txns[["amount"]]).__name__) # -> DataFrame (list of columns)
# loc: LABEL-based -> loc[row_labels, column_labels] (slices INCLUDE the end label!)print(txns.loc["T3", "amount"]) # -> 3000.0print(txns.loc["T1":"T2", "merchant"].tolist()) # -> ['AcmeMart', 'ZipFuel']
# iloc: POSITION-based -> iloc[row_positions, column_positions] (end EXCLUDED, like Python)print(txns.iloc[0, 1]) # -> 120.0print(txns.iloc[-2:].index.tolist()) # -> ['T3', 'T4']
# Boolean filtering: the WHERE clausebig_acme = txns[(txns["amount"] > 100) & (txns["merchant"] == "AcmeMart")]print(big_acme.index.tolist()) # -> ['T1', 'T3']
# loc accepts a boolean mask for rows AND a column selection — the cleanest formprint(txns.loc[txns["amount"] < 100, "merchant"].tolist()) # -> ['ZipFuel', 'BookNook']
# query(): SQL-flavoured string expressions; @ references Python variableslimit = 100print(txns.query("amount > @limit and merchant != 'ZipFuel'").index.tolist()) # -> ['T1', 'T3']
# Handy predicatesprint(txns[txns["merchant"].isin(["ZipFuel", "BookNook"])].index.tolist()) # -> ['T2', 'T4']print(txns[txns["amount"].between(50, 500)].index.tolist()) # -> ['T1', 'T4']Gotcha —
locslices are inclusive.df.loc["T1":"T2"]includesT2, whiledf.iloc[0:2]excludes position 2. Labels have no “next” value to stop before, so pandas includes the endpoint.
Deep Dive: Index Alignment
This is the pandas behaviour that most surprises engineers from other languages. Arithmetic between Series aligns on index labels, not positions. Labels that don’t match produce NaN.
import pandas as pd
yesterday = pd.Series({"AcmeMart": 100.0, "ZipFuel": 50.0, "BookNook": 30.0})today = pd.Series({"ZipFuel": 70.0, "AcmeMart": 130.0, "CoffeeCo": 12.0}) # different order and keys
change = today - yesterdayprint(change.to_dict())# -> {'AcmeMart': 30.0, 'BookNook': nan, 'CoffeeCo': nan, 'ZipFuel': 20.0}
# Fill missing labels instead of producing NaN:print(today.sub(yesterday, fill_value=0).to_dict())# -> {'AcmeMart': 30.0, 'BookNook': -30.0, 'CoffeeCo': 12.0, 'ZipFuel': 20.0}Alignment is a feature: you can combine results computed separately (per-merchant totals from two days) without sorting or joining by hand. It’s also a trap: after filtering or sorting, a DataFrame keeps its original index labels, and assigning a Series computed elsewhere can line up rows you didn’t expect. When you genuinely want positional behaviour, use .to_numpy() or reset_index(drop=True).
import pandas as pd
df = pd.DataFrame({"amount": [10.0, 20.0, 30.0]})filtered = df[df["amount"] > 15] # index is now [1, 2], not [0, 1]print(filtered.index.tolist()) # -> [1, 2]
scores = pd.Series([0.9, 0.1]) # index [0, 1]filtered = filtered.assign(score=scores) # aligns on labels 1 and 2!print(filtered["score"].tolist()) # -> [0.1, nan] (surprise)
filtered = filtered.assign(score=scores.to_numpy()) # positionalprint(filtered["score"].tolist()) # -> [0.9, 0.1]Creating Columns
import numpy as npimport pandas as pd
txns = pd.DataFrame({ "merchant": ["AcmeMart", "ZipFuel", "AcmeMart", "BookNook"], "amount": [120.0, 45.5, 3000.0, 80.0], "ts": pd.to_datetime(["2026-09-01 09:15", "2026-09-01 10:02", "2026-09-01 23:40", "2026-09-02 08:30"]),})
# Vectorised arithmetic — never loop over rows to do this.txns["amount_log"] = np.log1p(txns["amount"]).round(3)
# CASE WHEN with np.where (two branches) and np.select (many branches)txns["size"] = np.where(txns["amount"] > 1000, "large", "small")txns["band"] = np.select( [txns["amount"] < 50, txns["amount"] < 500], ["low", "mid"], default="high",)
# Binning continuous values: pd.cuttxns["bucket"] = pd.cut(txns["amount"], bins=[0, 100, 1000, np.inf], labels=["<100", "100-1k", "1k+"])
# .dt accessor for datetime columns, .str accessor for stringstxns["hour"] = txns["ts"].dt.hourtxns["is_night"] = txns["hour"].between(0, 5) | (txns["hour"] >= 23)txns["merchant_lc"] = txns["merchant"].str.lower().str.replace("mart", "_mart")
print(txns[["amount", "band", "bucket", "hour", "is_night"]].to_string(index=False))# amount band bucket hour is_night# 120.0 mid 100-1k 9 False# 45.5 low <100 10 False# 3000.0 high 1k+ 23 True# 80.0 mid <100 8 False
# assign(): returns a NEW DataFrame — ideal for method chainsenriched = txns.assign(amount_usd=lambda d: d["amount"] / 83.0)print("amount_usd" in txns.columns, "amount_usd" in enriched.columns) # -> False TrueDeep Dive: groupby — GROUP BY and Window Functions
groupby follows the split–apply–combine pattern: split the rows into groups by key, apply a function to each group, combine the results. What you get back depends on the kind of function you apply:
| Method | Returns | SQL analogue |
|---|---|---|
.agg(...) / .sum(), .mean() … | One row per group | GROUP BY |
.transform(...) | One value per original row | Window function OVER (PARTITION BY ...) |
.filter(...) | The original rows of groups that pass a test | HAVING, but keeping detail rows |
.apply(...) | Anything — flexible but slow | (a stored procedure per group) |
Aggregation: one row per group
import pandas as pd
txns = pd.DataFrame({ "customer_id": ["C1", "C2", "C1", "C3", "C2", "C1", "C3", "C2"], "merchant": ["AcmeMart", "ZipFuel", "AcmeMart", "BookNook", "AcmeMart", "ZipFuel", "BookNook", "ZipFuel"], "amount": [120.0, 45.5, 3000.0, 80.0, 15000.0, 60.0, 95.0, 22.0], "is_fraud": [0, 0, 1, 0, 1, 0, 0, 0],})
# SELECT customer_id, COUNT(*), SUM(amount), MAX(amount), AVG(is_fraud) GROUP BY customer_idper_customer = txns.groupby("customer_id").agg( n_txns=("amount", "size"), # named aggregation: new_name=(column, function) total=("amount", "sum"), largest=("amount", "max"), fraud_rate=("is_fraud", "mean"),)print(per_customer)# n_txns total largest fraud_rate# customer_id# C1 3 3180.0 3000.0 0.333333# C2 3 15067.5 15000.0 0.333333# C3 2 175.0 95.0 0.000000
# Group keys become the index; reset_index() turns them back into a column (like SQL output).print(per_customer.reset_index().columns.tolist())# -> ['customer_id', 'n_txns', 'total', 'largest', 'fraud_rate']
# Multiple keys -> a MultiIndex; unstack() pivots the inner key into columns.spend = txns.groupby(["customer_id", "merchant"])["amount"].sum()print(spend.unstack(fill_value=0))# merchant AcmeMart BookNook ZipFuel# customer_id# C1 3120.0 0.0 60.0# C2 15000.0 0.0 67.5# C3 0.0 175.0 0.0transform: the window function — the feature-engineering workhorse
For ML features you usually want each transaction compared with its customer’s behaviour. That’s a window function, and in pandas it’s transform: the result has the same length and index as the input, so it can be assigned straight back as a column.
import pandas as pd
txns = pd.DataFrame({ "customer_id": ["C1", "C2", "C1", "C3", "C2", "C1", "C3", "C2"], "amount": [120.0, 45.5, 3000.0, 80.0, 15000.0, 60.0, 95.0, 22.0],})
g = txns.groupby("customer_id")["amount"]
# AVG(amount) OVER (PARTITION BY customer_id)txns["cust_mean"] = g.transform("mean").round(1)# How unusual is this amount for THIS customer?txns["amount_vs_mean"] = (txns["amount"] / txns["cust_mean"]).round(2)# Share of the customer's total spendtxns["share"] = (txns["amount"] / g.transform("sum")).round(3)
print(txns)# customer_id amount cust_mean amount_vs_mean share# 0 C1 120.0 1060.0 0.11 0.038# 1 C2 45.5 5022.5 0.01 0.003# 2 C1 3000.0 1060.0 2.83 0.943# 3 C3 80.0 87.5 0.91 0.457# 4 C2 15000.0 5022.5 2.99 0.996# 5 C1 60.0 1060.0 0.06 0.019# 6 C3 95.0 87.5 1.09 0.543# 7 C2 22.0 5022.5 0.00 0.001Gotcha — data leakage hides here.
transform("mean")uses all of a customer’s transactions, including ones that happen after the row in question. For a real-time fraud model that’s cheating: at scoring time, the future isn’t available. Time-aware features need expanding or rolling windows ordered by timestamp — shown next — and Part 9 returns to leakage in detail.
Time Series: Ordering, Expanding and Rolling Windows
import pandas as pd
txns = pd.DataFrame({ "customer_id": ["C1", "C2", "C1", "C3", "C2", "C1", "C3", "C2"], "amount": [120.0, 45.5, 3000.0, 80.0, 15000.0, 60.0, 95.0, 22.0], "ts": pd.to_datetime([ "2026-09-01 09:15", "2026-09-01 10:02", "2026-09-01 23:40", "2026-09-02 08:30", "2026-09-02 02:11", "2026-09-02 12:00", "2026-09-03 18:45", "2026-09-03 19:05", ]),})
txns = txns.sort_values(["customer_id", "ts"]).reset_index(drop=True)g = txns.groupby("customer_id")
# ROW_NUMBER() OVER (PARTITION BY customer ORDER BY ts)txns["nth_txn"] = g.cumcount() + 1
# Mean of the customer's PREVIOUS transactions only (no peeking at the current or future rows)txns["prev_mean"] = g["amount"].transform(lambda s: s.shift(1).expanding().mean())
# Time since the customer's previous transaction, in hourstxns["hours_since_prev"] = (g["ts"].diff().dt.total_seconds() / 3600).round(1)
print(txns[["customer_id", "ts", "amount", "nth_txn", "prev_mean", "hours_since_prev"]].to_string(index=False))# customer_id ts amount nth_txn prev_mean hours_since_prev# C1 2026-09-01 09:15:00 120.0 1 NaN NaN# C1 2026-09-01 23:40:00 3000.0 2 120.00 14.4# C1 2026-09-02 12:00:00 60.0 3 1560.00 12.3# C2 2026-09-01 10:02:00 45.5 1 NaN NaN# C2 2026-09-02 02:11:00 15000.0 2 45.50 16.2# C2 2026-09-03 19:05:00 22.0 3 7522.75 40.9# C3 2026-09-02 08:30:00 80.0 1 NaN NaN# C3 2026-09-03 18:45:00 95.0 2 80.00 34.2
# Resampling: bucket a time-indexed series into fixed intervals (daily totals here).daily = txns.set_index("ts")["amount"].resample("D").sum()print(daily.to_dict())# -> {Timestamp('2026-09-01 00:00:00'): 3165.5, Timestamp('2026-09-02 00:00:00'): 15140.0, Timestamp('2026-09-03 00:00:00'): 117.0}
# Time-based rolling window: total spend in the trailing 24 hours, per customer.rolling_24h = ( txns.set_index("ts") .groupby("customer_id")["amount"] .rolling("24h") .sum())print(rolling_24h.loc["C1"].tolist()) # -> [120.0, 3120.0, 3060.0]Tip — velocity features (“how many transactions in the last hour?”, “how much spent in 24 hours?”) are among the strongest fraud signals, and
groupby(...).rolling("1h")computes them for millions of rows in seconds.
Joins: merge
import pandas as pd
txns = pd.DataFrame({ "txn_id": ["T1", "T2", "T3", "T4"], "merchant_id": ["M1", "M2", "M1", "M9"], # M9 has no reference data "amount": [120.0, 45.5, 3000.0, 80.0],})merchants = pd.DataFrame({ "merchant_id": ["M1", "M2", "M3"], "name": ["AcmeMart", "ZipFuel", "BookNook"], "mcc": ["5411", "5541", "5942"],})
# INNER JOIN (default how="inner")inner = txns.merge(merchants, on="merchant_id")print(inner["txn_id"].tolist()) # -> ['T1', 'T2', 'T3']
# LEFT JOIN with an indicator column showing where each row matchedleft = txns.merge(merchants, on="merchant_id", how="left", indicator=True)print(left[["txn_id", "name", "_merge"]].to_string(index=False))# txn_id name _merge# T1 AcmeMart both# T2 ZipFuel both# T3 AcmeMart both# T4 NaN left_only
# validate= asserts the join cardinality — catches duplicate keys that would multiply rows.checked = txns.merge(merchants, on="merchant_id", how="left", validate="many_to_one")print(len(checked) == len(txns)) # -> True
# Different key names: left_on / right_on. UNION ALL: pd.concat.more = pd.DataFrame({"txn_id": ["T5"], "merchant_id": ["M3"], "amount": [9.99]})print(len(pd.concat([txns, more], ignore_index=True))) # -> 5Gotcha — silent row explosion. If the right table has duplicate keys, a merge multiplies rows — exactly like SQL, but without a DBA to notice. Always pass
validate="many_to_one"(or"one_to_one") when you expect a lookup join.
Missing Data
import numpy as npimport pandas as pd
df = pd.DataFrame({ "amount": [120.0, np.nan, 80.0, 45.0], "country": ["IN", None, "US", "IN"], "age_days": pd.array([400, None, 12, 900], dtype="Int64"), # nullable integer dtype})
print(df.isna().sum().to_dict()) # -> {'amount': 1, 'country': 1, 'age_days': 1}print(df["amount"].mean()) # -> 81.66666666666667 (NaN skipped by default)
filled = df.fillna({"amount": df["amount"].median(), "country": "UNKNOWN"})print(filled["amount"].tolist()) # -> [120.0, 80.0, 80.0, 45.0]print(filled["country"].tolist()) # -> ['IN', 'UNKNOWN', 'US', 'IN']
print(len(df.dropna())) # -> 3 (drop rows with ANY missing value)print(df["age_days"].dtype) # -> Int64 (capital I: stays integer despite NA)
# A "was missing" flag is often a strong feature in itself — keep it before imputing.df["amount_missing"] = df["amount"].isna().astype(int)print(df["amount_missing"].tolist()) # -> [0, 1, 0, 0]Gotcha — integers with a missing value become floats in the classic NumPy-backed dtypes: an
int64column with oneNaNturns intofloat64. Use nullable dtypes ("Int64","boolean","string") when that matters, e.g. for IDs.
Deep Dive: Copy-on-Write and the Chained-Assignment Trap
Older pandas tutorials are full of SettingWithCopyWarning. The underlying problem: does df[mask] return a view or a copy? It depended on internal details, so writing through it sometimes changed the original and sometimes didn’t.
pandas 3.0 settled this with Copy-on-Write (CoW): every indexing result behaves as a copy. Data is shared lazily under the hood and copied only when someone writes. The rule is now simple — modifying a derived object never modifies its parent — and it means “chained assignment” never works:
import pandas as pd
df = pd.DataFrame({"merchant": ["AcmeMart", "ZipFuel"], "amount": [120.0, 15000.0]})
# WRONG: chained assignment. df[...] returns a new object; setting on it is lost.# pandas 3 emits ChainedAssignmentError (a warning) and df is NOT modified.df[df["amount"] > 10_000]["amount"] = 10_000print(df["amount"].tolist()) # -> [120.0, 15000.0]
# RIGHT: one .loc call with row selector AND column selector.df.loc[df["amount"] > 10_000, "amount"] = 10_000print(df["amount"].tolist()) # -> [120.0, 10000.0]
# Derived objects are independent: modifying 'sub' never touches 'df'.sub = df[df["merchant"] == "AcmeMart"]sub.loc[:, "amount"] = 0.0print(df["amount"].tolist()) # -> [120.0, 10000.0]Tip — If you’re on pandas 2.x, opt in with
pd.options.mode.copy_on_write = Trueto get the same semantics and future-proof your code. The rule of thumb is identical in both: assign with a singledf.loc[rows, cols] = value, neverdf[...][...] = value.
Vectorise, Don’t apply (and Never iterrows)
df.apply(f, axis=1) calls a Python function once per row. iterrows() is worse: it builds a Series object per row. Both bring back the interpreter overhead NumPy freed you from.
import timeimport numpy as npimport pandas as pd
rng = np.random.default_rng(0)df = pd.DataFrame({"amount": rng.lognormal(4, 1, 200_000), "fx": rng.uniform(1, 90, 200_000)})
start = time.perf_counter()slow = df.apply(lambda row: row["amount"] * row["fx"], axis=1) # Python call per rowapply_s = time.perf_counter() - start
start = time.perf_counter()fast = df["amount"] * df["fx"] # one vectorised opvec_s = time.perf_counter() - start
print(np.allclose(slow, fast)) # -> Trueprint(f"apply: {apply_s:.2f}s, vectorised: {vec_s * 1000:.1f}ms") # often 100x+ apart| Need | Use instead of apply |
|---|---|
| Arithmetic across columns | Column expressions: df.a * df.b |
| If/else | np.where, np.select, .mask, .where |
| Lookup / mapping | .map(dict), merge |
| String ops | .str.* accessor |
| Date parts | .dt.* accessor |
| Per-group logic | groupby(...).agg / transform with built-in names |
Method Chaining: SQL-Style Readability
Idiomatic modern pandas reads top to bottom like a query, with each step returning a new DataFrame. It avoids a graveyard of intermediate variables (df2, df_final, df_final_v2).
import pandas as pd
txns = pd.DataFrame({ "customer_id": ["C1", "C2", "C1", "C3", "C2", "C1", "C3", "C2"], "merchant": ["AcmeMart", "ZipFuel", "AcmeMart", "BookNook", "AcmeMart", "ZipFuel", "BookNook", "ZipFuel"], "amount": [120.0, 45.5, 3000.0, 80.0, 15000.0, 60.0, 95.0, 22.0], "is_fraud": [0, 0, 1, 0, 1, 0, 0, 0],})
report = ( txns .query("amount >= 50") # WHERE .assign(amount_k=lambda d: d["amount"] / 1000) # computed column .groupby("merchant", as_index=False) # GROUP BY .agg(txns=("amount", "size"), spend_k=("amount_k", "sum"), frauds=("is_fraud", "sum")) .query("txns >= 2") # HAVING .sort_values("spend_k", ascending=False) # ORDER BY .round(2))print(report.to_string(index=False))# merchant txns spend_k frauds# AcmeMart 3 18.12 2# BookNook 2 0.18 0Building Sentinel’s Feature Table
Putting it together: a realistic, leakage-aware feature table built from raw transactions — the input to Part 9’s model. This uses a synthetic dataset of 20,000 transactions so you can run it anywhere.
import numpy as npimport pandas as pd
rng = np.random.default_rng(42)n = 20_000
# --- synthetic raw data -------------------------------------------------------raw = pd.DataFrame({ "customer_id": rng.integers(1, 1_001, n).astype(str), "merchant": rng.choice(["AcmeMart", "ZipFuel", "BookNook", "CoffeeCo", "GadgetHub"], n), "amount": rng.lognormal(mean=4.0, sigma=1.0, size=n).round(2), "ts": pd.Timestamp("2026-06-01") + pd.to_timedelta(rng.integers(0, 90 * 24 * 3600, n), unit="s"),})
# --- feature engineering, as one chain ---------------------------------------features = ( raw .sort_values(["customer_id", "ts"]) .assign( hour=lambda d: d["ts"].dt.hour, is_night=lambda d: d["ts"].dt.hour.isin([0, 1, 2, 3, 4, 5]).astype(int), log_amount=lambda d: np.log1p(d["amount"]), # history-only features: shift(1) excludes the current transaction cust_prev_mean=lambda d: d.groupby("customer_id")["amount"] .transform(lambda s: s.shift(1).expanding().mean()), cust_txn_count=lambda d: d.groupby("customer_id").cumcount(), mins_since_prev=lambda d: d.groupby("customer_id")["ts"].diff().dt.total_seconds() / 60, ) .assign( amount_vs_history=lambda d: d["amount"] / d["cust_prev_mean"], merchant=lambda d: d["merchant"].astype("category"), # compact + model-friendly ))
print(features.shape) # -> (20000, 11)print(features["cust_prev_mean"].isna().sum() <= 1_000) # -> True (first txn per customer has no history)print(features["merchant"].dtype) # -> categoryprint(features.memory_usage(deep=True).sum() < 3_000_000) # -> TrueTips, Tricks & Gotchas
Tip — Parquet over CSV.
df.to_parquet("txns.parquet")stores types, compresses well, and loads 5–20× faster than CSV. CSVs lose dtypes (dates become strings, IDs lose leading zeros). Use CSV for exchange, Parquet for everything else.
Tip —
categorydtype for low-cardinality strings (merchant, country, channel) cuts memory dramatically and speeds upgroupby.
Gotcha — IDs read as numbers.
read_csvturns"00123"into123. Passdtype={"customer_id": "string"}.
Tip — Too big for memory?
pd.read_csv(path, chunksize=500_000)streams DataFrames (Part 5’s lazy idea, vectorised). For larger-than-memory analytics, look at Polars (a Rust DataFrame library with a lazy query optimiser) or DuckDB (run SQL directly on Parquet files and DataFrames). Both interoperate with pandas.
Gotcha —
inplace=Truerarely saves memory and breaks method chains. Prefer reassignment:df = df.dropna().
Key Takeaways
| Concept | Remember |
|---|---|
| Structures | Series = labelled 1-D array; DataFrame = dict of Series with a shared index |
| Selection | loc = labels (inclusive slices), iloc = positions; filter with boolean masks |
| Alignment | Operations align on index labels — mismatches become NaN |
groupby | agg = GROUP BY, transform = window function, filter = HAVING |
| Joins | merge(how=..., validate=...); concat for UNION ALL |
| Time | .dt, shift, expanding, rolling("24h"), resample |
| Missing data | isna, fillna, dropna; nullable dtypes for ints and strings |
| Copy-on-Write | Derived objects never modify parents; assign with one .loc[...] |
| Performance | Vectorise; avoid apply(axis=1) and iterrows |
Story Closing
Arjun’s feature pipeline shrank to a single readable chain — about sixty lines that ran over three years of transactions in under two minutes. He caught one leakage bug himself: a “customer average” feature that had quietly included each transaction’s own amount. Priya noticed he’d started writing validate="many_to_one" on every merge without being asked.
“So,” she said. “You have features. Shall we see if they predict anything?”
The fraud team had labelled ninety days of transactions: 0.8% confirmed fraud. Arjun’s first instinct was to train a model and report its accuracy. Priya’s expression suggested that would be a mistake.
In Part 9, Arjun trains Sentinel’s first model with scikit-learn — and learns why a 99.2% accurate fraud detector can be completely useless.
This is Part 8 of a 10-part series: “Python for Java Developers: From Streams to Tensors.”