A コーディング — itertools.groupby × functools.cache × Generator × TypeAlias (PEP 695) × StrEnum × Decimal × frozen dataclass(キャンペーンイベント集計 Bad→Good)

2026-06-15 (Day 70) 月曜 コーディング ★★★★☆ Python 3.12 / itertools / functools / Generator / TypeAlias PEP 695 良いコード設計入門 Ch2 / Ch4 / Ch6 / Ch7 / Ch10 / Ch11

概要

🔗

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 として返すこと
  • StrEnumEventTypeCLICK / OPEN / CONVERT)を定義し、未知イベントを ValueError で拒否すること
  • Decimal で金額集計すること(float 不可)
  • Google スタイル docstring・インラインコメント・名前付き定数を含めること
期待する回答形式: 問題点の列挙(番号付き)+ 改善後コード(Google スタイル docstring・インラインコメント・名前付き定数含む)+ 実行例(input→output)+ 適用した設計パターン名と書籍の対応章

悪いコード (Before)

このコードには 7つの設計上の問題 が隠れています。見つけてみてください。
bad_event_aggregator.py — O(N²)ループ・float精度誤差・型なし・メモ化なし
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
問題点サマリー(7点)
1型ヒントなし(Ch2) — 引数・戻り値に型なし。IDE 補完・型チェッカーが使えない。type EventRow = dict[...](PEP 695)+ StrEnum EventType で型安全に
2float で金額集計(Ch2)float の精度誤差が売上集計に誤差を生む。Decimal + ROUND_HALF_UP で精度保証
3O(N²) ループ(Ch11)for cid in set(): filter rows はキャンペーン数 × 行数の二重ループ。sorted + groupby で O(N log N) の1パス集計に
4メモ化なし(重複計算)get_discount_rate(cid) をループ内で毎回呼ぶ。@functools.cache でバッチ内1回だけ計算に
5未知 event_type を無視(Ch10)if/elif に else がなく不正イベントを黙って無視。EventType(str(...)) で即 ValueError
6dict で結果を返す(Ch4) — タイポで KeyError。@dataclass(frozen=True, slots=True) CampaignSummary で型安全に
7非決定的な順序(Ch11)set() で campaign_id を取得するため出力順序が実行ごとに変わる。sorted + groupby で campaign_id 昇順が保証される

ヒント(段階的開示)

ヒント1 — 方向性
list[dict] を受け取る関数は「何が来ても動く」ように見えるが、型情報がないため IDE 補完が効かず、キーのタイポで KeyError が起きる。TypeAliasEventRow を定義し、StrEnumEventType を型安全にする。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)yield
  • sorted(_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型ヒントなし型安全性 Ch2type EventRow = dict[...](PEP 695)+ StrEnum EventType
2float で金額集計(精度誤差)型安全性 Ch2Decimal + ROUND_HALF_UP + quantize で精度保証
3O(N²) ループPythonic Ch11sorted + itertools.groupby で O(N log N) 1パス集計
4メモ化なし(重複計算)パフォーマンス@functools.cache でバッチ内1回だけ実行
5未知 event_type を無視エラー処理 Ch10EventType(str(row["event_type"])) で即 ValueError
6dict で結果を返す値オブジェクト Ch4@dataclass(frozen=True, slots=True) CampaignSummary
7非決定的な順序(set 依存)Pythonic Ch11sorted + groupby で campaign_id 昇順が保証される

模範解答

Before — O(N²)ループ・float精度誤差・型なし・メモ化なし・非決定的順序
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
After — sorted+groupby × functools.cache × Generator × PEP 695 × frozen dataclass
"""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)

list[EventRow] BigQuery 行データ type alias (PEP 695) _iter_events() Generator (yield) EventType() → ValueError Decimal quantize → (cid, EventType, Decimal) sorted() key=campaign_id O(N log N) itertools.groupby() key=campaign_id match event_type: CLICK/OPEN/CONVERT → per-campaign 集計 @functools.cache _calc_discount_rate(cid: str) バッチ内1回だけ計算 Campaign Summary frozen dataclass slots=True @property cvr EventType (StrEnum) CLICK / OPEN / CONVERT 未知 → ValueError Decimal ROUND_HALF_UP quantize("1") 円単位 type EventRow PEP 695 TypeAlias dict[str, str|int|float] ● データフロー: list[EventRow] → Generator → sorted → groupby → list[CampaignSummary](campaign_id 昇順)

ポイント解説

1 type EventRow = ...(PEP 695 TypeAlias)

Python 3.12 から type 文で型エイリアスを宣言できる。旧来の EventRow: TypeAlias = dict[...] より型チェッカーの識別が正確で、再帰型エイリアスも書ける。type 文はモジュールスコープにのみ書けることに注意(関数内では書けない)。

2 functools.cache はイミュータブル引数のみ

dict は unhashable なので cache に渡すと TypeErrorstr / int / frozenset など hashable な引数に変換してから渡すこと。lru_cache(maxsize=None) と等価だが cache の方が簡潔。バッチ終了後に _calc_discount_rate.cache_clear() でキャッシュを明示的に解放できる。

3 ジェネレータ → sorted()groupby() のパターン

sorted() はイテレータを受け取ってリストを返す(内部でリスト化)。groupby() は連続する同一キーのみをグループ化するため、事前ソートが必須。このパターンは O(N log N) + O(N) メモリで完結し、O(N²) フィルタループより大幅に速い。

4 slots=True のメモリ効率

@dataclass(frozen=True, slots=True)__dict__ を持たず、各属性が直接スロットに格納される。Python 3.10+ では dataclassslots=True が追加され、クラスに __slots__ を手動で書く必要がなくなった。100万件の集計オブジェクト生成時にメモリを約40〜60 bytes/オブジェクト削減できる。

5 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 の壁を越えた「読めるコード」を書ける。

自己評価(あとで記入)