Window Functions Advanced: сложные примеры в DWH и Data Lake
Продвинутые паттерны оконных функций: SCD Type 2, дедупликация, сессионизация, Gaps & Islands, time-series forward-fill, воронки, когортный анализ и оптимизация тяжёлых window-пайплайнов.
Оконные функции как язык аналитики¶
В предыдущем уроке мы разобрали механику window functions: WindowSpec, frame specification, ROWS vs RANGE. Этот урок - про применение этих инструментов в реальных задачах DWH и Data Lake.
Большинство производственных KPI и BI-метрик невозможно эффективно реализовать без оконных вычислений. Что происходит с ценой товара между изменениями - forward fill. Когда заканчивается сессия пользователя - sessionization. Кто купил во второй раз после первой покупки - cohort retention. Изменился ли статус заказа - change detection через lag.
Все эти задачи объединяет одна идея: для каждой строки нужен контекст соседних строк той же сущности, упорядоченных по времени. Именно это и делают window functions.
SCD Type 2: история изменений через lead¶
Slowly Changing Dimension Type 2 (SCD2) - стандарт DWH для хранения истории изменений сущностей. Каждое изменение создаёт новую строку с полями valid_from / valid_to вместо обновления старой.
Проблема: источник данных приходит как поток изменений - каждая строка фиксирует новое состояние сущности на конкретный момент времени. Нужно превратить этот поток в интервальную историю.
Генерация valid_to через lead¶
Ключевой приём: lead(valid_from, 1) даёт дату начала следующей версии. valid_to текущей записи = дата следующей минус один день. Для последней версии lead вернёт NULL - это текущая запись с valid_to = 9999-12-31.
import pyspark.sql.functions as F
from pyspark.sql import Window
from pyspark.sql.functions import col, lead, row_number, coalesce, lit
# Сырой поток изменений (Bronze/Silver)
changes = spark.read.parquet("/data/silver/price_changes/")
# Окно: для каждого товара, хронологически
scd_w = Window.partitionBy("product_id").orderBy("change_date")
scd2 = changes \
.withColumn("valid_from", col("change_date")) \
.withColumn(
"valid_to_raw",
lead("change_date", 1).over(scd_w)
) \
.withColumn(
"valid_to",
coalesce(
F.date_sub(col("valid_to_raw"), 1), # предыдущий день до следующей версии
lit("9999-12-31").cast("date") # текущая запись
)
) \
.withColumn(
"is_current",
col("valid_to_raw").isNull()
) \
.drop("valid_to_raw", "change_date")
scd2.orderBy("product_id", "valid_from").show(truncate=False)
# +-----------+-------+----------+----------+----------+
# |product_id |price |valid_from|valid_to |is_current|
# +-----------+-------+----------+----------+----------+
# |42 |100.00 |2024-01-01|2024-03-14|false |
# |42 |120.00 |2024-03-15|2024-05-31|false |
# |42 |115.00 |2024-06-01|9999-12-31|true |
# +-----------+-------+----------+----------+----------+
Этот паттерн на порядок эффективнее self-join: вместо O(N²) соединения двух копий таблицы - один lead в O(N log N) (сортировка внутри партиций).
Point-in-time lookup: цена на заданную дату¶
После построения SCD2 таблицы часто нужно найти цену товара на конкретный момент времени. Это называется point-in-time join или AS-OF join:
# Найти цену товара на 2024-04-10
query_date = "2024-04-10"
current_price = scd2.filter(
(col("valid_from") <= query_date) &
(col("valid_to") >= query_date)
)
# Результат: product_id=42, price=120 (версия с 2024-03-15 по 2024-05-31)
Дедупликация с приоритетом: latest + most-complete¶
Стандартная dropDuplicates() оставляет произвольную строку. row_number() с правильным orderBy позволяет реализовать любую стратегию выбора «лучшей» версии.
# Задача: из нескольких версий одной записи оставить:
# 1. Самую свежую по updated_at
# 2. При равенстве updated_at - ту, где больше непустых полей
from pyspark.sql.functions import row_number, col, when
# Считаем количество непустых полей как proxy для "полноты"
completeness = (
when(col("email").isNotNull(), 1).otherwise(0) +
when(col("phone").isNotNull(), 1).otherwise(0) +
when(col("address").isNotNull(), 1).otherwise(0) +
when(col("birth_date").isNotNull(), 1).otherwise(0)
)
dedup_w = Window.partitionBy("customer_id").orderBy(
col("updated_at").desc_nulls_last(),
completeness.desc()
)
best_version = customers \
.withColumn("completeness", completeness) \
.withColumn("rn", row_number().over(dedup_w)) \
.filter(col("rn") == 1) \
.drop("rn", "completeness")
Late Arriving Data: обработка опоздавших данных¶
В Lakehouse-архитектурах данные часто приходят с задержкой. Запись с event_time = 2024-01-10 может прийти в Bronze 15 января. Если Silver уже записан, нужна стратегия обновления.
Паттерн для Bronze → Silver с поздними данными: дедупликация с приоритетом по ingestion time. Более позднее поступление данных (большее ingested_at) считается «более правильным».
# Bronze содержит все версии, включая поздние корректировки
bronze_w = Window.partitionBy("event_id").orderBy(
col("ingested_at").desc() # самая свежая запись о событии - победитель
)
silver = (
bronze
.withColumn("rn", row_number().over(bronze_w))
.filter(col("rn") == 1)
.drop("rn", "ingested_at")
)
Gaps & Islands: поиск непрерывных последовательностей¶
Gaps & Islands - классический класс задач с временными рядами. «Islands» - непрерывные последовательности событий, «Gaps» - разрывы между ними.
Задача: пользователь посещает сайт. Нужно определить непрерывные периоды «активности» (island), разделённые длительными паузами (gap). Это обобщение sessionization: здесь нас интересует не только начало сессии, но и полная история всех непрерывных периодов.
Метод «разности номеров строк»¶
Элегантный метод для группировки последовательных строк с одинаковым состоянием:
# Задача: найти непрерывные периоды, когда цена товара была выше 100
# Данные: price history по дням
from pyspark.sql.functions import row_number, sum as spark_sum, col
price_w = Window.partitionBy("product_id").orderBy("date")
all_w = Window.partitionBy("product_id").orderBy("date")
# Шаг 1: пронумеровать все строки и строки с условием отдельно
df = prices \
.withColumn("rn_all", row_number().over(price_w)) \
.withColumn("above_100", col("price") > 100) \
.withColumn("rn_above", row_number().over(
Window.partitionBy("product_id", (col("price") > 100)).orderBy("date")
))
# Шаг 2: разность rn_all - rn_above - постоянна внутри одного острова
# (если строки не прерываются, разность одна и та же)
df = df.withColumn("island_id", col("rn_all") - col("rn_above"))
# Шаг 3: агрегировать острова
islands = df.filter(col("above_100")) \
.groupBy("product_id", "island_id") \
.agg(
F.min("date").alias("island_start"),
F.max("date").alias("island_end"),
F.count("*").alias("island_days"),
F.avg("price").alias("avg_price"),
)
Почему это работает: если последовательность непрерывна, то при каждом шаге вперёд rn_all и rn_above увеличиваются одновременно, и разность остаётся постоянной. Когда появляется gap (строка не соответствует условию), rn_above не увеличивается, а при следующем совпадении разность меняется - новый остров.
Sessionization с метаданными сессии¶
Расширение базового sessionization из предыдущего урока - добавляем детальные метрики для каждой сессии:
from pyspark.sql.functions import lag, col, unix_timestamp, sum as spark_sum, count, first, last
SESSION_GAP = 30 * 60 # 30 минут
events = spark.read.parquet("/data/silver/clickstream/")
user_w = Window.partitionBy("user_id").orderBy("ts")
# Шаг 1: пометить начала сессий
with_flags = events \
.withColumn("prev_ts", lag("ts", 1).over(user_w)) \
.withColumn("gap_sec", col("ts") - col("prev_ts")) \
.withColumn(
"is_new_session",
col("prev_ts").isNull() | (col("gap_sec") > SESSION_GAP)
)
# Шаг 2: ID сессии через накопленную сумму флагов
with_session = with_flags.withColumn(
"session_id",
spark_sum(col("is_new_session").cast("int")).over(user_w)
)
# Шаг 3: метрики сессии через full-partition window
sess_w = Window.partitionBy("user_id", "session_id")
full_w = sess_w.rowsBetween(Window.unboundedPreceding, Window.unboundedFollowing)
enriched = with_session \
.withColumn("session_start", first("ts").over(full_w)) \
.withColumn("session_end", last("ts").over(full_w)) \
.withColumn("session_events", count("*").over(sess_w)) \
.withColumn("session_dur_sec", last("ts").over(full_w) - first("ts").over(full_w)) \
.withColumn("has_purchase", (spark_sum(
(col("event_type") == "purchase").cast("int")
).over(sess_w) > 0))
Time-Series: Forward Fill и Backward Fill¶
Forward fill (также ffill, last observation carried forward) - заполнение пропусков последним известным значением. Классический пример: котировки акций существуют только в торговые дни, но нам нужно значение для каждого дня.
from pyspark.sql.functions import last, first
# Генерируем полный calendar spine (все даты в диапазоне)
date_range = spark.sql("""
SELECT explode(sequence(
date('2024-01-01'), date('2024-12-31'), interval 1 day
)) AS date
""")
# Cross join с уникальными product_id
products = prices.select("product_id").distinct()
spine = products.crossJoin(date_range)
# Left join с реальными данными
full = spine.join(prices, ["product_id", "date"], "left")
# Forward fill: last non-null value в накопленном окне
ffill_w = Window.partitionBy("product_id").orderBy("date") \
.rowsBetween(Window.unboundedPreceding, Window.currentRow)
filled = full.withColumn(
"price_filled",
last("price", ignorenulls=True).over(ffill_w)
)
# Backward fill (если нужно заполнить и в обратную сторону)
bfill_w = Window.partitionBy("product_id").orderBy("date") \
.rowsBetween(Window.currentRow, Window.unboundedFollowing)
filled = filled.withColumn(
"price_bfill",
first("price_filled", ignorenulls=True).over(bfill_w)
)
Параметр ignorenulls=True в last() - ключевой. Без него last вернёт NULL, если последняя строка в frame содержит NULL. С ним - берётся последнее непустое значение во всём накопленном диапазоне.
Period-over-Period сравнение через lag¶
Сравнение метрик с аналогичным периодом прошлого года (Year-over-Year) или прошлого месяца без join:
from pyspark.sql.functions import lag, round as spark_round
# Месячная выручка уже агрегирована: (year, month, region, revenue)
monthly = spark.read.parquet("/data/gold/monthly_revenue/")
# Окно: внутри региона, хронологически по месяцам
mom_w = Window.partitionBy("region").orderBy("year", "month")
# Window 12 месяцев назад - lag с offset 12 для Year-over-Year
enriched = monthly \
.withColumn("prev_month_rev", lag("revenue", 1).over(mom_w)) \
.withColumn("prev_year_rev", lag("revenue", 12).over(mom_w)) \
.withColumn(
"mom_growth_pct",
spark_round(
(col("revenue") - col("prev_month_rev")) / col("prev_month_rev") * 100,
1
)
) \
.withColumn(
"yoy_growth_pct",
spark_round(
(col("revenue") - col("prev_year_rev")) / col("prev_year_rev") * 100,
1
)
)
Воронка (Funnel Analysis)¶
Funnel analysis - определение, сколько пользователей прошли каждый шаг последовательности: page_view → add_to_cart → checkout → purchase. Ключевой вопрос: «прошёл ли пользователь шаг B после шага A в рамках одной сессии?»
from pyspark.sql.functions import max as spark_max, min as spark_min, when
# Шаг 1: определить первое появление каждого события у пользователя в сессии
session_w = Window.partitionBy("user_id", "session_id")
events_with_first = events_sessionized.withColumn(
"first_view", spark_min(when(col("event") == "page_view", col("ts"))).over(session_w)
).withColumn(
"first_cart", spark_min(when(col("event") == "add_to_cart", col("ts"))).over(session_w)
).withColumn(
"first_checkout", spark_min(when(col("event") == "checkout", col("ts"))).over(session_w)
).withColumn(
"first_purchase", spark_min(when(col("event") == "purchase", col("ts"))).over(session_w)
)
# Шаг 2: взять одну строку на сессию и проверить порядок событий
funnel = events_with_first \
.groupBy("user_id", "session_id", "first_view", "first_cart", "first_checkout", "first_purchase") \
.agg(F.count("*").alias("events")) \
.withColumn(
"reached_cart",
col("first_cart").isNotNull() & (col("first_cart") > col("first_view"))
) \
.withColumn(
"reached_checkout",
col("first_checkout").isNotNull() & (col("first_checkout") > col("first_cart"))
) \
.withColumn(
"converted",
col("first_purchase").isNotNull() & (col("first_purchase") > col("first_checkout"))
)
# Шаг 3: конверсия по воронке
funnel_stats = funnel.agg(
F.count("*").alias("sessions"),
F.sum(col("reached_cart").cast("int")).alias("to_cart"),
F.sum(col("reached_checkout").cast("int")).alias("to_checkout"),
F.sum(col("converted").cast("int")).alias("purchased"),
)
Когортный анализ (Cohort Retention)¶
Когортный анализ - отслеживание поведения группы пользователей (когорты), объединённых общим признаком (обычно - месяц первой покупки), в последующие периоды.
from pyspark.sql.functions import min as spark_min, months_between, floor
orders = spark.read.parquet("/data/silver/orders/").filter(col("status") == "COMPLETED")
# Шаг 1: первая покупка каждого клиента (когорта)
first_purchase = orders \
.groupBy("customer_id") \
.agg(spark_min("order_date").alias("cohort_date")) \
.withColumn("cohort_month", F.date_trunc("month", col("cohort_date")))
# Шаг 2: join с полной историей заказов
orders_with_cohort = orders.join(first_purchase, "customer_id") \
.withColumn("order_month", F.date_trunc("month", col("order_date"))) \
.withColumn(
"months_since_first",
floor(months_between(col("order_month"), col("cohort_month"))).cast("int")
)
# Шаг 3: когортная матрица
cohort_matrix = orders_with_cohort \
.groupBy("cohort_month", "months_since_first") \
.agg(F.countDistinct("customer_id").alias("active_customers"))
# Шаг 4: retention rate через window
cohort_size_w = Window.partitionBy("cohort_month")
cohort_with_size = cohort_matrix \
.withColumn(
"cohort_size",
spark_max(when(col("months_since_first") == 0, col("active_customers"))).over(cohort_size_w)
) \
.withColumn(
"retention_rate",
spark_round(col("active_customers") / col("cohort_size"), 3)
)
# Pivot для читаемой таблицы: строки = когорта, колонки = месяц
cohort_pivot = cohort_with_size \
.groupBy("cohort_month") \
.pivot("months_since_first", list(range(13))) \
.agg(spark_max("retention_rate"))
Change Detection: отслеживание изменений состояния¶
Паттерн change detection через lag: когда нужно определить момент смены состояния сущности и посчитать метрики каждого состояния.
from pyspark.sql.functions import lag, col, when, sum as spark_sum
# История статусов заказа: (order_id, status, changed_at)
status_w = Window.partitionBy("order_id").orderBy("changed_at")
status_history = orders_events \
.withColumn("prev_status", lag("status", 1).over(status_w)) \
.withColumn(
"is_status_change",
col("prev_status").isNull() | (col("status") != col("prev_status"))
) \
.withColumn(
"state_id",
spark_sum(col("is_status_change").cast("int")).over(status_w)
)
# Длительность каждого состояния
state_duration_w = Window.partitionBy("order_id", "state_id")
full_state_w = state_duration_w.rowsBetween(
Window.unboundedPreceding, Window.unboundedFollowing
)
with_duration = status_history \
.withColumn("state_start", F.first("changed_at").over(full_state_w)) \
.withColumn("state_end", F.last("changed_at").over(full_state_w)) \
.withColumn(
"state_duration_hrs",
(F.last("changed_at").over(full_state_w).cast("long") -
F.first("changed_at").over(full_state_w).cast("long")) / 3600
)
Fraud Detection: аномалии в транзакционных потоках¶
Паттерн обнаружения подозрительной активности: много транзакций за короткий период или сумма сильно выше среднего.
from pyspark.sql.functions import count, sum as spark_sum, avg, stddev
# Окно: транзакции за последние 10 минут для каждого счёта
ten_min_sec = 10 * 60
fraud_w = Window \
.partitionBy("account_id") \
.orderBy(col("ts").cast("long")) \
.rangeBetween(-ten_min_sec, Window.currentRow)
# Окно за 24 часа для расчёта базовой линии
day_sec = 24 * 60 * 60
baseline_w = Window \
.partitionBy("account_id") \
.orderBy(col("ts").cast("long")) \
.rangeBetween(-day_sec, Window.currentRow)
flagged = transactions \
.withColumn("tx_count_10m", count("*").over(fraud_w)) \
.withColumn("sum_10m", spark_sum("amount").over(fraud_w)) \
.withColumn("avg_24h", avg("amount").over(baseline_w)) \
.withColumn("stddev_24h", stddev("amount").over(baseline_w)) \
.withColumn(
"is_suspicious",
(col("tx_count_10m") >= 5) |
(col("sum_10m") > col("avg_24h") * 3) |
(col("amount") > col("avg_24h") + 3 * col("stddev_24h")) # 3σ выброс
)
alerts = flagged.filter(col("is_suspicious"))
Оптимизация тяжёлых window-пайплайнов¶
Проблема: multiple WindowSpec = multiple shuffle¶
Каждый уникальный WindowSpec (разный partitionBy или orderBy) создаёт отдельный Exchange (shuffle) в физическом плане. Pipeline с 5 разными окнами = 5 shuffle через сеть.
Стратегия: группировать все функции с одинаковым partitionBy + orderBy в один select. Spark умеет объединить их в один WindowExec после одного shuffle.
# ПЛОХО: 3 отдельных withColumn с одним WindowSpec → 3 WindowExec
w = Window.partitionBy("customer_id").orderBy("order_date")
df = df.withColumn("seq", row_number().over(w))
df = df.withColumn("prev_date", lag("order_date").over(w))
df = df.withColumn("running", F.sum("amount").over(w))
# ХОРОШО: один select → Spark может объединить в один WindowExec
df = df.select(
"*",
row_number().over(w).alias("seq"),
lag("order_date").over(w).alias("prev_date"),
F.sum("amount").over(w.rowsBetween(Window.unboundedPreceding, 0)).alias("running"),
)
Pre-sorting для cache reuse¶
Если данные уже отсортированы по нужным колонкам, SortExec внутри WindowExec можно избежать. Но Spark редко использует это автоматически - необходимо явно контролировать порядок операций.
# Сохранить отсортированные данные в Parquet с партиционированием по ключу
orders.repartition(col("customer_id")) \
.sortWithinPartitions("customer_id", "order_date") \
.write.mode("overwrite").parquet("/data/sorted_orders/")
# При последующем чтении + window с тем же partitionBy/orderBy
# Spark может пропустить Exchange и использовать уже отсортированные данные
sorted_orders = spark.read.parquet("/data/sorted_orders/")
sorted_orders.withColumn("seq", row_number().over(w)) # нет Exchange в physical plan
Incremental window processing¶
Вместо полного пересчёта за весь исторический период - обработка только новых данных с обновлением агрегатов.
# Паттерн: накопленные итоги инкрементально
# Шаг 1: загрузить предыдущие агрегаты (Gold)
prev_gold = spark.read.parquet("/data/gold/customer_stats/")
# Шаг 2: обработать только новые данные (сегодняшний Bronze)
new_silver = spark.read.parquet("/data/silver/orders/today/")
# Шаг 3: join + пересчёт только изменившихся ключей
updated_customers = new_silver.select("customer_id").distinct()
affected_gold = prev_gold.join(updated_customers, "customer_id")
unchanged_gold = prev_gold.join(updated_customers, "customer_id", "left_anti")
# Пересчёт только для изменившихся клиентов
new_stats = (
spark.read.parquet("/data/silver/orders/full/")
.filter(col("customer_id").isin(updated_customers.rdd.flatMap(lambda x: x).collect()))
.withColumn("seq", row_number().over(w))
# ... остальные метрики
)
# Merge и запись
new_gold = unchanged_gold.union(new_stats)
new_gold.write.mode("overwrite").parquet("/data/gold/customer_stats/")
Практика: Customer 360 Mart¶
Построение полной аналитической витрины клиента, объединяющей несколько паттернов:
import pyspark.sql.functions as F
from pyspark.sql import Window
from pyspark.sql.functions import (
col, row_number, lag, lead, first, last,
sum as spark_sum, count, avg, min as spark_min, max as spark_max,
datediff, months_between, floor, coalesce, lit, when,
percent_rank, ntile
)
orders = spark.read.parquet("/data/silver/orders/").filter(col("status") == "COMPLETED")
# === Window Specs ===
cust_time_w = Window.partitionBy("customer_id").orderBy("order_date")
cust_full_w = Window.partitionBy("customer_id")
cust_time_full = cust_time_w.rowsBetween(Window.unboundedPreceding, Window.unboundedFollowing)
cust_cum_w = cust_time_w.rowsBetween(Window.unboundedPreceding, Window.currentRow)
cust_roll3_w = cust_time_w.rowsBetween(-2, Window.currentRow)
region_w = Window.partitionBy("region").orderBy(col("amount").desc())
# === Обогащение ===
customer_mart = orders.select(
"order_id", "customer_id", "region", "order_date", "amount", "category",
# --- Порядковая аналитика ---
row_number().over(cust_time_w).alias("order_seq"),
lag("order_date", 1).over(cust_time_w).alias("prev_order_date"),
lead("order_date", 1).over(cust_time_w).alias("next_order_date"),
# --- Временные метрики ---
datediff(col("order_date"), lag("order_date", 1).over(cust_time_w)).alias("days_since_prev"),
datediff(lead("order_date", 1).over(cust_time_w), col("order_date")).alias("days_to_next"),
# --- Накопленная выручка (Running LTV) ---
spark_sum("amount").over(cust_cum_w).alias("cumulative_ltv"),
count("*").over(cust_cum_w).alias("cumulative_orders"),
# --- Скользящие метрики ---
avg("amount").over(cust_roll3_w).alias("rolling_avg_3tx"),
spark_max("amount").over(cust_roll3_w).alias("rolling_max_3tx"),
# --- Полная статистика клиента ---
spark_sum("amount").over(cust_full_w).alias("total_ltv"),
count("*").over(cust_full_w).alias("total_orders"),
spark_min("order_date").over(cust_full_w).alias("first_order_date"),
spark_max("order_date").over(cust_full_w).alias("last_order_date"),
# --- Флаги ---
(row_number().over(cust_time_w) == 1).alias("is_first_order"),
lead("order_date", 1).over(cust_time_w).isNull().alias("is_last_order"),
# --- Ранжирование в регионе ---
percent_rank().over(region_w).alias("region_pct_rank"),
ntile(4).over(Window.partitionBy("region").orderBy("amount")).alias("region_quartile"),
)
# === Сегментация клиентов ===
final_mart = customer_mart \
.withColumn(
"customer_segment",
when(col("total_ltv") >= 50000, "VIP")
.when(col("total_ltv") >= 10000, "HIGH_VALUE")
.when(col("total_orders") == 1, "ONE_TIME")
.otherwise("REGULAR")
) \
.withColumn(
"days_since_last_order",
datediff(F.current_date(), col("last_order_date"))
) \
.withColumn(
"churn_risk",
when(col("days_since_last_order") > 180, "HIGH")
.when(col("days_since_last_order") > 90, "MEDIUM")
.otherwise("LOW")
)
# Запись в Gold (последний заказ как снапшот клиента)
snapshot = final_mart \
.filter(col("is_last_order")) \
.select(
"customer_id", "region", "total_ltv", "total_orders",
"first_order_date", "last_order_date", "days_since_last_order",
"customer_segment", "churn_risk", "region_pct_rank",
)
snapshot.write.mode("overwrite").parquet("/data/gold/customer_360/")
Задания для практики¶
Задание 1: SCD Type 2¶
Дан DataFrame price_changes с колонками (product_id, price, change_date). Постройте SCD2-историю: добавьте valid_from, valid_to, is_current. Проверьте результат: для каждого product_id должна быть ровно одна запись с is_current=True, а valid_to предыдущей должен быть на один день меньше valid_from следующей.
Решение
from pyspark.sql import Window
from pyspark.sql.functions import lead, col, coalesce, lit, date_sub
data = [
(1, 100.0, "2024-01-01"),
(1, 120.0, "2024-03-15"),
(1, 115.0, "2024-06-01"),
(2, 50.0, "2024-01-01"),
(2, 55.0, "2024-04-20"),
]
df = spark.createDataFrame(data, ["product_id", "price", "change_date"]) \
.withColumn("change_date", col("change_date").cast("date"))
scd_w = Window.partitionBy("product_id").orderBy("change_date")
result = df \
.withColumn("valid_from", col("change_date")) \
.withColumn("next_start", lead("change_date", 1).over(scd_w)) \
.withColumn("valid_to", coalesce(
date_sub(col("next_start"), 1),
lit("9999-12-31").cast("date")
)) \
.withColumn("is_current", col("next_start").isNull()) \
.drop("change_date", "next_start") \
.orderBy("product_id", "valid_from")
result.show()
# Проверка: только одна is_current=True на product_id
assert result.filter(col("is_current")).groupBy("product_id") \
.count().filter(col("count") != 1).count() == 0
Задание 2: Forward Fill¶
Дан DataFrame с котировками акций (ticker, date, price). Данные есть только за рабочие дни. Создайте полный календарный spine от 2024-01-01 до 2024-12-31 и заполните пропуски последним известным значением через last(ignorenulls=True).
Решение
from pyspark.sql import Window
from pyspark.sql.functions import last, explode, sequence, col, lit
prices = spark.createDataFrame([
("AAPL", "2024-01-02", 185.0),
("AAPL", "2024-01-03", 187.5),
("AAPL", "2024-01-08", 183.0), # пропуск 4,5,6,7
("GOOG", "2024-01-02", 140.0),
("GOOG", "2024-01-05", 142.0),
], ["ticker", "date", "price"]).withColumn("date", col("date").cast("date"))
# Генерируем spine
tickers = prices.select("ticker").distinct()
dates = spark.sql("SELECT explode(sequence(date('2024-01-01'), date('2024-01-10'), interval 1 day)) AS date")
spine = tickers.crossJoin(dates)
# Join с реальными данными
full = spine.join(prices, ["ticker", "date"], "left")
# Forward fill
ffill_w = Window.partitionBy("ticker").orderBy("date") \
.rowsBetween(Window.unboundedPreceding, Window.currentRow)
filled = full.withColumn(
"price_filled",
last("price", ignorenulls=True).over(ffill_w)
)
filled.show(20)
Задание 3: Gaps & Islands¶
Дана таблица статусов серверов (server_id, date, status). Status может быть UP или DOWN. Найдите все периоды непрерывного состояния DOWN для каждого сервера: начало, конец, продолжительность в днях.
Решение
from pyspark.sql import Window
from pyspark.sql.functions import row_number, col, min as spark_min, max as spark_max, datediff
data = [
("srv1", "2024-01-01", "UP"),
("srv1", "2024-01-02", "DOWN"),
("srv1", "2024-01-03", "DOWN"),
("srv1", "2024-01-04", "DOWN"),
("srv1", "2024-01-05", "UP"),
("srv1", "2024-01-08", "DOWN"),
("srv1", "2024-01-09", "DOWN"),
]
df = spark.createDataFrame(data, ["server_id", "date", "status"]) \
.withColumn("date", col("date").cast("date"))
# Нумерация всех строк и строк DOWN отдельно
all_w = Window.partitionBy("server_id").orderBy("date")
down_w = Window.partitionBy("server_id", (col("status") == "DOWN")).orderBy("date")
df = df \
.withColumn("rn_all", row_number().over(all_w)) \
.withColumn("rn_down", row_number().over(down_w)) \
.withColumn("island_id", col("rn_all") - col("rn_down"))
# Агрегировать DOWN-острова
down_islands = df.filter(col("status") == "DOWN") \
.groupBy("server_id", "island_id") \
.agg(
spark_min("date").alias("outage_start"),
spark_max("date").alias("outage_end"),
) \
.withColumn(
"outage_days",
datediff(col("outage_end"), col("outage_start")) + 1
) \
.drop("island_id") \
.orderBy("server_id", "outage_start")
down_islands.show()
# +--------+------------+----------+-----------+
# |server_id|outage_start|outage_end|outage_days|
# +---------+------------+----------+-----------+
# | srv1| 2024-01-02|2024-01-04| 3|
# | srv1| 2024-01-08|2024-01-09| 2|
# +---------+------------+----------+-----------+
Задание 4: Cohort Retention¶
Постройте матрицу когортного удержания (retention matrix) по датасету заказов. Строки = месяц первой покупки, колонки = номер месяца с момента первой покупки (0, 1, 2, ..., 6). Значения = retention rate (доля вернувшихся клиентов). Используйте pivot для получения читаемой матрицы.