Approach: parse and normalize timestamps to UTC, filter events up to (but not after) the reference date, compute features using vectorized pandas operations (no Python-level per-user loops). For cyclical hour_of_day use sin/cos of hour; day_of_week frequency as normalized counts over last 7 days. Use groupby aggregations on filtered slices for windowed counts (7d, 30d) and last-event time.python
import pandas as pd
import numpy as np
def build_user_features(events: pd.DataFrame, reference_date: str):
"""
events: DataFrame with columns ['user_id','event_time','event_type','value']
reference_date: ISO8601 string in UTC (e.g. "2025-01-01T00:00:00Z")
Returns DataFrame indexed by user_id with features:
- hour_sin, hour_cos (aggregated over last 7 days)
- day_of_week_freq_0..6 (fractions over last 7 days)
- time_since_last_event (seconds) or NaN if none
- count_last_7_days, count_last_30_days
"""
# Parse timestamps robustly, force UTC
ts = pd.to_datetime(events['event_time'], utc=True, errors='coerce')
df = events.loc[:, ['user_id', 'event_type', 'value']].copy()
df['event_time'] = ts
ref = pd.to_datetime(reference_date, utc=True)
# Drop rows with invalid timestamps or timestamps after reference
df = df.dropna(subset=['event_time'])
df = df[df['event_time'] <= ref]
if df.empty:
# return empty features frame if no valid events
return pd.DataFrame(columns=[
'hour_sin','hour_cos',
*[f'dow_freq_{i}' for i in range(7)],
'time_since_last_event','count_last_7_days','count_last_30_days'
]).astype(float)
# Precompute time-based columns
df['hour'] = df['event_time'].dt.hour.astype(np.int8)
df['dow'] = df['event_time'].dt.weekday.astype(np.int8) # 0=Mon..6=Sun
# cyclical encoding
radians = 2 * np.pi * df['hour'] / 24.0
df['hour_sin'] = np.sin(radians)
df['hour_cos'] = np.cos(radians)
# Window masks (vectorized filtering)
last7_cut = ref - pd.Timedelta(days=7)
last30_cut = ref - pd.Timedelta(days=30)
df_last7 = df[df['event_time'] >= last7_cut]
df_last30 = df[df['event_time'] >= last30_cut]
# Aggregations for hour sin/cos over last 7 days
agg_hour = df_last7.groupby('user_id')[['hour_sin','hour_cos']].mean().rename(
columns={'hour_sin':'hour_sin','hour_cos':'hour_cos'}
)
# Day-of-week frequency over last 7 days: compute counts per (user_id, dow) then normalize
if not df_last7.empty:
dow_counts = (df_last7.groupby(['user_id','dow'])
.size()
.unstack(fill_value=0)
.reindex(columns=range(7), fill_value=0))
dow_freq = dow_counts.div(dow_counts.sum(axis=1).replace(0, np.nan), axis=0)
# rename columns
dow_freq.columns = [f'dow_freq_{i}' for i in dow_freq.columns]
else:
dow_freq = pd.DataFrame(index=agg_hour.index, columns=[f'dow_freq_{i}' for i in range(7)])
# time_since_last_event: for events <= ref take the max timestamp per user, subtract from ref
last_ts = df.groupby('user_id')['event_time'].max()
time_since = (ref - last_ts).dt.total_seconds().rename('time_since_last_event')
# counts
count7 = df_last7.groupby('user_id').size().rename('count_last_7_days')
count30 = df_last30.groupby('user_id').size().rename('count_last_30_days')
# Combine all features into single frame (outer join to include users with any activity)
out = pd.concat([agg_hour, dow_freq, time_since, count7, count30], axis=1)
# Ensure numeric dtypes
out = out.sort_index()
return out.reset_index().rename(columns={'index':'user_id'}).set_index('user_id')
# Example usage:
# feats = build_user_features(events_df, "2025-12-01T00:00:00Z")
Key points and reasoning:- Parsing to UTC (pd.to_datetime(..., utc=True)) handles timezone-aware and naive ISO strings consistently; errors='coerce' removes bad rows.- Use vectorized groupby and boolean masks to compute windowed features without per-user loops. Filtering slices (df_last7, df_last30) reduces work for aggregations.- Cyclical hour features use sin/cos so ML models learn circularity (midnight adjacent to 23:00).- Day-of-week frequencies produced as normalized counts per user over last 7 days.- time_since_last_event uses max timestamp <= reference_date for correctness.Scalability:- For tens of millions of rows ensure enough memory; consider processing by user shards, using Dask or PySpark (same logic but distributed), or storing pre-partitioned parquet and computing per-partition aggregates then reducing. Groupby on a large single-machine frame may require >RAM; using categorical user_id or sorting by user_id helps if streaming/rolling approaches are used.Complexity:- Time: O(N) to parse and filter, groupby cost depends on number of distinct users U (roughly O(N) hashing + O(U) aggregation).- Space: O(N) for input; intermediate filtered views reduce work but still memory-bound.