概要
sorted + itertools.groupby でキャンペーン別集計(Ch11)
for cid in set(all_cids): filter(lambda r: r["campaign_id"]==cid, rows) のような O(N²) ループは大量データで致命的に遅い。sorted(key=campaign_id) → itertools.groupby(key=campaign_id) で O(N log N) の1パスで集計できる。groupby はソート済み前提なので必ず事前 sort すること。
functools.cache でメモ化(重複計算排除)
_get_discount_rate(cid) をループ内で毎回呼ぶと同一 campaign_id で何度も BigQuery/DB を叩く。@functools.cache(= lru_cache(maxsize=None))でイミュータブルな引数の結果をメモ化し、バッチ内で1回だけ実行させる。dict は unhashable なので cache 不可、str/int で渡すこと。
ジェネレータで中間リスト排除(Ch11)
BigQuery の大量行(100万行)を [parse(row) for row in rows] でリスト化するとメモリが爆発する。yield を使うジェネレータ関数にすることで、sorted() が内部でイテレータを逐次消費するため O(1) のメモリで処理できる。
PEP 695 TypeAlias × StrEnum で型安全(Ch2)
Python 3.12 の type EventRow = dict[str, str | int | float] 構文(PEP 695)で型エイリアスを宣言。旧来の EventRow = TypeAlias[...] より型チェッカーの識別が正確。StrEnum EventType で "click"/"open"/"convert" のマジックストリングを排除し、未知値は即 ValueError。
問題
ECサイト MOps チームでは、Argo Workflows から BigQuery に格納された「注文イベントログ」を日次で読み込み、キャンペーン別・ユーザーセグメント別の集計レポートを生成するバッチ処理を Python で実装している。以下の「悪いコード」は、list[dict] を受け取ってキャンペーン別集計を行う aggregate_events 関数の実装例です。問題点を全て洗い出し、itertools.groupby / functools.cache / ジェネレータ式 / TypeAlias(PEP 695 type 文)/ @dataclass(frozen=True, slots=True) / StrEnum / Decimal を使って Bad→Good にリファクタリングしてください。
制約・前提条件
- Python 3.12+
itertools.groupby(ソート済みイテラブル前提)でキャンペーン別にグループ化することfunctools.cacheを使って「同一 campaign_id の割引率計算」をメモ化すること- ジェネレータ式(
yield)で中間リストを作らないメモリ効率の良い実装にすること type文(PEP 695)でEventRow = dict[str, str | int | float]の型エイリアスを定義すること- 集計結果は
@dataclass(frozen=True, slots=True)のCampaignSummaryとして返すこと StrEnumでEventType(CLICK / OPEN / CONVERT)を定義し、未知イベントをValueErrorで拒否することDecimalで金額集計すること(float不可)- Google スタイル docstring・インラインコメント・名前付き定数を含めること
悪いコード (Before)
def get_discount_rate(campaign_id):
# 問題1: 型ヒントなし・呼ぶたびに再計算(メモ化なし)
seed = int(campaign_id[-2:]) if campaign_id[-2:].isdigit() else 5
return seed / 100 # 問題2: float で返す(精度誤差)
def aggregate_events(rows):
# 問題3: 型ヒントなし(rows が何か不明)
campaign_ids = list(set(r["campaign_id"] for r in rows))
results = []
for cid in campaign_ids:
# 問題4: O(N²) — キャンペーン数 × 行数でフィルタ
subset = [r for r in rows if r["campaign_id"] == cid]
clicks = 0
opens = 0
converts = 0
revenue = 0.0 # 問題2: float 金額集計
for r in subset:
if r["event_type"] == "click":
clicks += 1
elif r["event_type"] == "open":
opens += 1
elif r["event_type"] == "convert":
converts += 1
revenue += float(r.get("revenue", 0)) # float 精度誤差
# 問題5: 未知 event_type を無視(握りつぶし)
rate = get_discount_rate(cid)
net = revenue * (1 - rate)
# 問題6: dict で返す(型安全性なし・タイポで KeyError)
results.append({
"campaign_id": cid,
"click_count": clicks,
"open_count": opens,
"convert_count": converts,
"total_revenue": net,
})
# 問題7: 結果の順序が set() に依存して非決定的
return results
type EventRow = dict[...](PEP 695)+ StrEnum EventType で型安全にfloat の精度誤差が売上集計に誤差を生む。Decimal + ROUND_HALF_UP で精度保証for cid in set(): filter rows はキャンペーン数 × 行数の二重ループ。sorted + groupby で O(N log N) の1パス集計にget_discount_rate(cid) をループ内で毎回呼ぶ。@functools.cache でバッチ内1回だけ計算にif/elif に else がなく不正イベントを黙って無視。EventType(str(...)) で即 ValueError@dataclass(frozen=True, slots=True) CampaignSummary で型安全にset() で campaign_id を取得するため出力順序が実行ごとに変わる。sorted + groupby で campaign_id 昇順が保証されるヒント(段階的開示)
ヒント1 — 方向性
list[dict] を受け取る関数は「何が来ても動く」ように見えるが、型情報がないため IDE 補完が効かず、キーのタイポで KeyError が起きる。TypeAlias で EventRow を定義し、StrEnum で EventType を型安全にする。groupby はソート前提なので sorted() と組み合わせること。functools.cache はイミュータブルな引数にしか使えない点に注意(dict は不可、str で渡す)。
ヒント2 — アプローチ
type EventRow = dict[str, str | int | float]で型エイリアス定義(PEP 695)class EventType(StrEnum): CLICK = "click" / OPEN = "open" / CONVERT = "convert"@dataclass(frozen=True, slots=True)のCampaignSummaryを定義(+@property cvr)@functools.cacheで_calc_discount_rate(campaign_id: str) -> Decimalをメモ化def _iter_events(rows)をジェネレータにして(campaign_id, EventType, Decimal)をyieldsorted(_iter_events(rows), key=lambda t: t[0])→itertools.groupby(..., key=lambda t: t[0])match event_type: case EventType.CLICK: ... case _: raise ValueError
ヒント3 — コードの骨格
import itertools, functools
from decimal import Decimal, ROUND_HALF_UP
from enum import StrEnum
from dataclasses import dataclass
from typing import Final, Iterator
type EventRow = dict[str, str | int | float] # PEP 695
ZERO: Final[Decimal] = Decimal("0")
PERCENT: Final[Decimal] = Decimal("100")
MONEY_PLACES: Final[Decimal] = Decimal("1")
class EventType(StrEnum):
CLICK = "click"
OPEN = "open"
CONVERT = "convert"
@dataclass(frozen=True, slots=True)
class CampaignSummary:
campaign_id: str
click_count: int
open_count: int
convert_count: int
total_revenue: Decimal
@property
def cvr(self) -> Decimal:
if self.click_count == 0:
return ZERO
return (
Decimal(self.convert_count) / Decimal(self.click_count) * PERCENT
).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
@functools.cache
def _calc_discount_rate(campaign_id: str) -> Decimal:
seed = int(campaign_id[-2:]) if campaign_id[-2:].isdigit() else 5
return Decimal(seed) / PERCENT
def _iter_events(rows: list[EventRow]) -> Iterator[tuple[str, EventType, Decimal]]:
for row in rows:
cid = str(row["campaign_id"])
etype = EventType(str(row["event_type"])) # 未知 → ValueError
rev = Decimal(str(row.get("revenue", "0"))).quantize(
MONEY_PLACES, rounding=ROUND_HALF_UP
)
yield cid, etype, rev
def aggregate_events(rows: list[EventRow]) -> list[CampaignSummary]:
if not rows:
return []
sorted_events = sorted(_iter_events(rows), key=lambda t: t[0])
summaries: list[CampaignSummary] = []
for campaign_id, group in itertools.groupby(sorted_events, key=lambda t: t[0]):
clicks = opens = converts = 0
total_revenue = ZERO
for _cid, event_type, revenue in group:
match event_type:
case EventType.CLICK: clicks += 1
case EventType.OPEN: opens += 1
case EventType.CONVERT: converts += 1; total_revenue += revenue
case _: raise ValueError(f"未知: {event_type}")
discount_rate = _calc_discount_rate(campaign_id)
net = (total_revenue * (Decimal("1") - discount_rate)).quantize(
MONEY_PLACES, rounding=ROUND_HALF_UP
)
summaries.append(CampaignSummary(campaign_id, clicks, opens, converts, net))
return summaries
問題点分析(7点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | 型ヒントなし | 型安全性 Ch2 | type EventRow = dict[...](PEP 695)+ StrEnum EventType |
| 2 | float で金額集計(精度誤差) | 型安全性 Ch2 | Decimal + ROUND_HALF_UP + quantize で精度保証 |
| 3 | O(N²) ループ | Pythonic Ch11 | sorted + itertools.groupby で O(N log N) 1パス集計 |
| 4 | メモ化なし(重複計算) | パフォーマンス | @functools.cache でバッチ内1回だけ実行 |
| 5 | 未知 event_type を無視 | エラー処理 Ch10 | EventType(str(row["event_type"])) で即 ValueError |
| 6 | dict で結果を返す | 値オブジェクト Ch4 | @dataclass(frozen=True, slots=True) CampaignSummary |
| 7 | 非決定的な順序(set 依存) | Pythonic Ch11 | sorted + groupby で campaign_id 昇順が保証される |
模範解答
def get_discount_rate(campaign_id): # 型なし・毎回再計算
seed = int(campaign_id[-2:]) \
if campaign_id[-2:].isdigit() else 5
return seed / 100 # float 精度誤差
def aggregate_events(rows): # 型なし
campaign_ids = list(set(r["campaign_id"] # set → 非決定的順序
for r in rows))
results = []
for cid in campaign_ids:
subset = [r for r in rows # O(N²) フィルタ
if r["campaign_id"] == cid]
clicks = opens = converts = 0
revenue = 0.0 # float 精度誤差
for r in subset:
if r["event_type"] == "click": clicks += 1
elif r["event_type"] == "open": opens += 1
elif r["event_type"] == "convert":
converts += 1
revenue += float(r.get("revenue", 0))
# 未知 event_type を無視(握りつぶし)
rate = get_discount_rate(cid) # 毎回再計算
net = revenue * (1 - rate)
results.append({ # dict(型なし)
"campaign_id": cid,
"click_count": clicks,
"open_count": opens,
"convert_count": converts,
"total_revenue": net,
})
return results
"""event_aggregator.py
Ch2: StrEnum / TypeAlias PEP 695 / Decimal
Ch4: frozen dataclass CampaignSummary slots=True
Ch6: match 文(イベント種別集計)
Ch7: 名前付き定数 ZERO / PERCENT / MONEY_PLACES
Ch10: ValueError(未知 EventType を即拒否)
Ch11: Generator / sorted+groupby / O(N log N)
"""
from __future__ import annotations
import functools, itertools
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
from enum import StrEnum
from typing import Final, Iterator
# ── 名前付き定数(Ch7)───────────────────────────
ZERO: Final[Decimal] = Decimal("0")
PERCENT: Final[Decimal] = Decimal("100")
MONEY_PLACES: Final[Decimal] = Decimal("1") # 円単位
CVR_PLACES: Final[Decimal] = Decimal("0.01")
# ── PEP 695 TypeAlias(Python 3.12+)────────────
type EventRow = dict[str, str | int | float]
# ── StrEnum(Ch2)───────────────────────────────
class EventType(StrEnum):
CLICK = "click"
OPEN = "open"
CONVERT = "convert"
# ── 値オブジェクト(Ch4)───────────────────────
@dataclass(frozen=True, slots=True) # ← slots で軽量化
class CampaignSummary:
"""キャンペーン別集計(イミュータブル値オブジェクト)"""
campaign_id: str
click_count: int
open_count: int
convert_count: int
total_revenue: Decimal # 割引後・円単位 Decimal
@property
def cvr(self) -> Decimal:
"""CVR(%)= convert / click × 100"""
if self.click_count == 0:
return ZERO # ゼロ除算ガード(Ch10)
return (
Decimal(self.convert_count)
/ Decimal(self.click_count) * PERCENT
).quantize(CVR_PLACES, rounding=ROUND_HALF_UP)
# ── メモ化(functools.cache)────────────────────
@functools.cache # str はhashable → cache可
def _calc_discount_rate(cid: str) -> Decimal:
"""campaign_id → 割引率(バッチ内で1回だけ計算)"""
seed = int(cid[-2:]) if cid[-2:].isdigit() else 5
return Decimal(seed) / PERCENT
# ── ジェネレータ(Ch11)────────────────────────
def _iter_events(
rows: list[EventRow],
) -> Iterator[tuple[str, EventType, Decimal]]:
"""(campaign_id, EventType, revenue) を逐次 yield。
中間リストを作らないためメモリ O(1) を維持する。
"""
for row in rows:
cid = str(row["campaign_id"])
etype = EventType(str(row["event_type"])) # 未知→ValueError(Ch10)
rev = Decimal(str(row.get("revenue","0"))).quantize(
MONEY_PLACES, rounding=ROUND_HALF_UP
)
yield cid, etype, rev
# ── 集計メイン ──────────────────────────────────
def aggregate_events(
rows: list[EventRow],
) -> list[CampaignSummary]:
"""注文イベントをキャンペーン別に集計する。
sorted + groupby で O(N log N)、groupby は
ソート後の連続キーをグループ化する。
"""
if not rows: # 早期リターン(空ガード)
return []
# ① ジェネレータ → sorted(ソート済みをgroupbyへ)
sorted_events = sorted(
_iter_events(rows), key=lambda t: t[0] # campaign_id 昇順
)
summaries: list[CampaignSummary] = []
# ② groupby でキャンペーン別グループ化
for cid, group in itertools.groupby(
sorted_events, key=lambda t: t[0]
):
clicks = opens = converts = 0
total = ZERO
for _, event_type, revenue in group:
match event_type: # match 文(Ch6)
case EventType.CLICK:
clicks += 1
case EventType.OPEN:
opens += 1
case EventType.CONVERT:
converts += 1
total += revenue
case _:
raise ValueError(f"未知 EventType: {event_type}")
# ③ functools.cache でメモ化した割引率を適用
rate = _calc_discount_rate(cid)
net_rev = (total * (Decimal("1") - rate)).quantize(
MONEY_PLACES, rounding=ROUND_HALF_UP
)
summaries.append(
CampaignSummary(
campaign_id = cid,
click_count = clicks,
open_count = opens,
convert_count = converts,
total_revenue = net_rev, # 割引後売上(円)
)
)
return summaries # campaign_id 昇順(sorted 順序を維持)
# ── 実行例 ──────────────────────────────────────
if __name__ == "__main__":
sample: list[EventRow] = [
{"campaign_id":"CP05","event_type":"click", "revenue":0},
{"campaign_id":"CP05","event_type":"open", "revenue":0},
{"campaign_id":"CP05","event_type":"convert","revenue":9800},
{"campaign_id":"CP05","event_type":"click", "revenue":0},
{"campaign_id":"CP10","event_type":"click", "revenue":0},
{"campaign_id":"CP10","event_type":"convert","revenue":5500},
]
for s in aggregate_events(sample):
print(
f"{s.campaign_id}: "
f"click={s.click_count}, open={s.open_count}, "
f"convert={s.convert_count}, "
f"revenue={s.total_revenue}円, cvr={s.cvr}%"
)
# CP05: click=2, open=1, convert=1, revenue=9310円, cvr=50.00%
# CP10: click=1, open=0, convert=1, revenue=4950円, cvr=100.00%
設計図(SVG)
ポイント解説
type EventRow = ...(PEP 695 TypeAlias)
Python 3.12 から type 文で型エイリアスを宣言できる。旧来の EventRow: TypeAlias = dict[...] より型チェッカーの識別が正確で、再帰型エイリアスも書ける。type 文はモジュールスコープにのみ書けることに注意(関数内では書けない)。
functools.cache はイミュータブル引数のみ
dict は unhashable なので cache に渡すと TypeError。str / int / frozenset など hashable な引数に変換してから渡すこと。lru_cache(maxsize=None) と等価だが cache の方が簡潔。バッチ終了後に _calc_discount_rate.cache_clear() でキャッシュを明示的に解放できる。
sorted() → groupby() のパターン
sorted() はイテレータを受け取ってリストを返す(内部でリスト化)。groupby() は連続する同一キーのみをグループ化するため、事前ソートが必須。このパターンは O(N log N) + O(N) メモリで完結し、O(N²) フィルタループより大幅に速い。
slots=True のメモリ効率
@dataclass(frozen=True, slots=True) は __dict__ を持たず、各属性が直接スロットに格納される。Python 3.10+ では dataclass に slots=True が追加され、クラスに __slots__ を手動で書く必要がなくなった。100万件の集計オブジェクト生成時にメモリを約40〜60 bytes/オブジェクト削減できる。
match 文の網羅性チェック(Ch6)
case _: raise ValueError を書くことで、StrEnum に新値が追加されたとき(拡張忘れ)に即座に気づける。mypy/pyright は StrEnum の全値が case で網羅されているかを検証できる(exhaustiveness check)。if-elif チェーンより型安全性が高い。
実務への応用
MOps チームの Argo Workflows バッチで BigQuery からイベントデータを読み込み、キャンペーン別 KPI(CVR・売上)を集計する場面に直結する。
- BigQuery RowIterator との組み合わせ:
google.cloud.bigquery.Client.query(...).result()はイテレータを返す。_iter_eventsの引数をIterable[EventRow]にすれば BigQuery RowIterator を直接渡せ、全行のメモリ展開を回避できる。 - キャッシュの活用: キャンペーンの割引率・ターゲットセグメント条件など「バッチ内で変化しない設定値」は
functools.cacheでメモ化し、Firestore/CloudSQL への不要なリード呼び出しを削減できる(コスト・レイテンシ改善)。 slots=Trueの効果: 100万件のCampaignSummaryを生成する場合、slots=Trueで 1オブジェクトあたり約50 bytes 削減 → 全体で 50 MB 削減。Argo Workflows Pod のメモリ上限(256Mi)での大量オブジェクト処理で有効。- dbt との連携: 集計結果の
CampaignSummaryを BigQuery に書き戻し、dbt のmart_campaign_kpi_dailyモデルで参照することで Looker Studio ダッシュボードに自動反映できる。
今日のまとめ
sorted + itertools.groupby で O(N log N) 集計、functools.cache で重複計算排除、ジェネレータで中間リスト排除という3つの組み合わせが、大量イベントデータを扱う MOps バッチの「メモリ効率・速度・型安全性」を同時に実現する Python 実務パターンの核心。type 文(PEP 695)と StrEnum で型の入口を固め、frozen dataclass + slots=True で出口を軽量・イミュータブルにすることで、junior→middle の壁を越えた「読めるコード」を書ける。