cswork / libraryOpen practice workspace

Coding questions · Boston Consulting Group

Transform messy transactions with pandas

This question evaluates proficiency in large-scale data manipulation and feature engineering using pandas, covering timestamp normalization across time zones, deduplication and last-write-wins semantics, data validation, memory-efficient aggregation, rolling-window statistics, and top-k categorical extraction for merchant categories.

You are given two CSVs.

transactions.csv

  • Columns: txn_id, user_id, ts_iso (ISO8601 with timezone), amount (decimal USD; refunds negative), merchant_cat, type {purchase, refund, chargeback}, updated_at (last write wins), dup_hint (string that can be identical across near-duplicate rows)
  • Sample rows:
 1, U1, 2024-03-31T23:55:00-0700, 120.00, groceries, purchase, 2024-04-01T00:02:00Z, A
 2, U1, 2024-03-31T23:58:10-0700, -20.00, groceries, refund, 2024-04-01T00:05:00Z, B
 3, U2, 2024-04-01T07:01:00+0200, 15.00, digital, purchase, 2024-04-01T05:05:00Z, C
 3, U2, 2024-04-01T07:01:00+0200, 15.00, digital, purchase, 2024-04-01T05:06:10Z, C (duplicate with later updated_at)
 4, U3, 2024-04-02T10:00:00Z, 500.00, travel, purchase, 2024-04-02T10:01:00Z, D
 5, U3, 2024-06-15T23:59:59-0400, 500.00, travel, chargeback, 2024-06-20T12:00:00Z, E
 6, U1, 2024-06-01T00:00:10Z, 0.00, fees, purchase, 2024-06-01T00:00:20Z, F
 7, U2, 2024-06-30T23:59:59-0700, 200.00, electronics, purchase, 2024-07-01T08:00:00Z, G

users.csv

  • Columns: user_id, signup_ts_iso, country, tz_name
  • Sample rows:
 U1, 2024-01-15T12:00:00Z, US, America/Los_Angeles
 U2, 2024-02-20T09:30:00Z, US, America/New_York
 U3, 2023-12-01T00:00:00Z, GB, Europe/London

Task (write idiomatic, production-ready pandas code without groupby.apply or explicit Python loops over rows; assume data can be 100M+ rows):

  1. Read both files, parse timestamps, and normalize ts_iso to UTC. Deduplicate transactions by txn_id keeping only the row with the max updated_at. Sanity-check and drop rows where amount is NaN, type is invalid, or ts_iso is outside [2023-01-01, 2025-12-31].
  2. Exclude refunds and chargebacks from spend features but keep them in a separate flag. Define net_spend as sum of amounts over type=='purchase' only.
  3. Build month-level features per user for the window 2024-03-01 through 2024-07-31 (inclusive, calendar months in the user’s tz_name, but aggregated after converting to UTC to avoid DST duplication):
  • active_month (1 if user has ≥1 purchase in that local month, else 0),
  • monthly_net_spend (sum of positive purchase amounts in that local month),
  • rolling_3m_median_spend computed over active months only (skip months with active_month=0; do not fill implicit zeros), aligned to month end.
  1. For each user-month, compute the top-3 merchant_cat by monthly_net_spend and emit them as categorical features cat1, cat2, cat3. Break ties by larger monthly_net_spend then lexicographic merchant_cat. If <3 categories exist, fill with 'None'.
  2. Output one row per user for the snapshot date 2024-07-31 containing: user_id, months_active_last_5m, total_net_spend_last_5m, had_any_refund_last_5m (boolean from refunds/chargebacks), rolling_3m_median_spend_at_2024_07, cat1_2024_07, cat2_2024_07, cat3_2024_07. Ensure results are idempotent if you re-run on the same inputs, and memory-efficient (hint: use categorical dtypes, proper indexing, and avoid exploding intermediate DataFrames). Explain any edge cases you handle (DST boundaries, zero-amount rows, duplicate near-same rows via dup_hint).

Monthly user spend features with time zones, deduplication, and rolling medians

Monthly user-spend features with time zones, deduplication, and a rolling median

You are given two tables, transactions and users. Write a single PostgreSQL query that produces exactly one row per user as a snapshot at 2024-07-31, with a fixed set of feature columns.

All timestamp columns are TIMESTAMP WITH TIME ZONE. The analysis revolves around each user's local calendar months, derived from their tz_name (an IANA zone such as America/Los_Angeles).

Business rules

1) Clean and deduplicate the transactions

  • Start from transactions.
  • Keep only rows where all of the following hold:
    • amount IS NOT NULL
    • type is one of ('purchase', 'refund', 'chargeback')
    • ts_iso is within the inclusive UTC range [2023-01-01 00:00:00+00, 2025-12-31 23:59:59+00]
  • Deduplicate by txn_id: when multiple rows share a txn_id, keep only the one with the greatest updated_at ("last write wins") and drop the rest.

2) Local month of each transaction

  • Convert ts_iso to the user's local time with AT TIME ZONE u.tz_name, then truncate to the first day of the local month (e.g. local 2024-04-15 → 2024-04-01). DST and day/month boundaries must be respected.

3) Analysis window

  • For every user, consider the five local months 2024-03, 2024-04, 2024-05, 2024-06, 2024-07 (i.e. month starts 2024-03-01 … 2024-07-01). A user-month with no qualifying transactions must still be considered (it is simply inactive).

4) Per-user, per-month building blocks

  • active_month = 1 if the user has at least one purchase (any amount) in that local month, else 0.
  • monthly_net_spend = sum of positive purchase amounts (type = 'purchase' AND amount > 0) in that local month. Zero/negative purchase amounts do not add to spend (so a month with only a $0 purchase is active with monthly_net_spend = 0). Refunds and chargebacks never count toward spend.
  • A month "has a refund" if it contains at least one refund or chargeback.

5) Rolling 3-month median (the key step)

  • For each user and month M, take the window of M and the two preceding calendar months (e.g. for 2024-07 the window is 2024-05, 2024-06, 2024-07).
  • Within that window, keep only months where active_month = 1, and compute the discrete median (percentile_disc(0.5)) of their monthly_net_spend.
  • If the window has no active months, the rolling median is NULL.

6) Top merchant categories for July 2024

  • For local month 2024-07 only, for each merchant_cat sum the positive purchase amounts (type = 'purchase' AND amount > 0) to get category_net_spend.
  • Rank categories by category_net_spend descending, breaking ties by merchant_cat ascending (lexicographic).
  • cat1_2024_07, cat2_2024_07, cat3_2024_07 are the 1st, 2nd and 3rd ranked categories. Fill any missing position with the literal string 'None' (so a user with no positive July spend gets 'None' in all three).

Required output (one row per user, ordered by user_id ascending)

columnmeaning
user_idthe user
months_active_last_5mcount of months in 2024-03…2024-07 with active_month = 1
total_net_spend_last_5msum of monthly_net_spend over 2024-03…2024-07
had_any_refund_last_5mTRUE if any of those months has a refund or chargeback, else FALSE
rolling_3m_median_spend_at_2024_07the rolling 3-month median for month 2024-07 (NULL if May–Jul has no active months)
cat1_2024_07, cat2_2024_07, cat3_2024_07top-3 July merchant categories, 'None' where missing

Every user in users must appear, even with no qualifying transactions. Use only relational constructs (joins, CTEs, window functions, ordered-set aggregates) — no procedural code. Sort the final result by user_id ascending.

Table transactions

columntypeconstraints
txn_idINTNOT NULL
user_idVARCHAR(10)NOT NULL
ts_isoTIMESTAMP WITH TIME ZONENOT NULL
amountDECIMAL(10,2)NOT NULL
merchant_catVARCHAR(50)NOT NULL
typeVARCHAR(20)NOT NULL
updated_atTIMESTAMP WITH TIME ZONENOT NULL
dup_hintVARCHAR(20)

Sample data:

amountdup_hintmerchant_catts_isotxn_idtypeupdated_atuser_id
120Agroceries2024-03-31T23:55:00-07001purchase2024-04-01T00:02:00ZU1
-20Bgroceries2024-03-31T23:58:10-07002refund2024-04-01T00:05:00ZU1
15Cdigital2024-04-01T07:01:00+02003purchase2024-04-01T05:05:00ZU2
15Cdigital2024-04-01T07:01:00+02003purchase2024-04-01T05:06:10ZU2
500Dtravel2024-04-02T10:00:00Z4purchase2024-04-02T10:01:00ZU3
500Etravel2024-06-15T23:59:59-04005chargeback2024-06-20T12:00:00ZU3
0Ffees2024-06-01T00:00:10Z6purchase2024-06-01T00:00:20ZU1
200Gelectronics2024-06-30T23:59:59-07007purchase2024-07-01T08:00:00ZU2

Table users

columntypeconstraints
user_idVARCHAR(10)PRIMARY KEY
signup_ts_isoTIMESTAMP WITH TIME ZONENOT NULL
countryVARCHAR(2)NOT NULL
tz_nameVARCHAR(64)NOT NULL

Sample data:

countrysignup_ts_isotz_nameuser_id
US2024-01-15T12:00:00ZAmerica/Los_AngelesU1
US2024-02-20T09:30:00ZAmerica/New_YorkU2
GB2023-12-01T00:00:00ZEurope/LondonU3

Expected output

One row per user as of 2024-07-31: 5-month activity/spend/refund rollup plus the July rolling 3-month median and top-3 July merchant categories, ordered by user_id.

cat1_2024_07cat2_2024_07cat3_2024_07had_any_refund_last_5mmonths_active_last_5mrolling_3m_median_spend_at_2024_07total_net_spend_last_5muser_id
NoneNoneNonetrue20120.00U1
electronicsNoneNonefalse2200.00215.00U2
NoneNoneNonetrue1NULL500.00U3

Hints

  1. Ordered-set aggregates like percentile_disc cannot be used as window functions in PostgreSQL. Build the rolling 3-month window with a self-join (current month joined to the prior 3 months) and aggregate with GROUP BY instead.
  2. Derive each transaction's local month with date_trunc('month', ts_iso AT TIME ZONE u.tz_name)::date so DST and month-boundary cases land in the correct local month.
  3. Cross join users with a generate_series of the five month starts, then LEFT JOIN the per-month aggregates so inactive months still appear and default to 0 / false.