| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383 |
- """
- 类目树节点热度计算。
- 六维(ext_pop / plat_sust_pop / plat_ly_pop / recent_pop / real_rov_7d / real_vov_7d)各自独立;
- 后两维存 rov_diff / vov_diff(相对全局基线):
- 1. 挂载点:将需求词级 avg/count 聚合为挂载点 avg/count
- 2. 树上任意节点:取其子树内全部「有数据」挂载点,
- score = sum(avg * count) / sum(count),并保留 avg 与 count 供上层聚合
- """
- from __future__ import annotations
- import logging
- from collections import defaultdict
- from dataclasses import dataclass, field
- from decimal import Decimal
- from types import SimpleNamespace
- from typing import Any
- from supply_infra.db.repositories.category_tree_weight_repo import (
- CategoryTreeWeightRepository,
- )
- from supply_infra.db.repositories.demand_belong_category_repo import (
- DemandBelongCategoryRepository,
- )
- from supply_infra.db.repositories.demand_popularity_stats_repo import (
- DemandPopularityStatsRepository,
- )
- from supply_infra.db.repositories.global_tree_category_repo import (
- GlobalTreeCategoryRepository,
- )
- from supply_infra.scoring.ranking import rank_to_scores
- from supply_infra.db.session import get_session
- logger = logging.getLogger(__name__)
- METRIC_KEYS: tuple[str, ...] = (
- "ext_pop",
- "plat_sust_pop",
- "plat_ly_pop",
- "recent_pop",
- "real_rov_7d",
- "real_vov_7d",
- )
- POP_DIM_KEYS: tuple[str, ...] = (
- "ext_pop",
- "plat_sust_pop",
- "plat_ly_pop",
- "recent_pop",
- )
- _UPSERT_BATCH = 500
- _RANK_UPDATE_BATCH = 500
- @dataclass
- class DimStats:
- avg: float = 0.0
- count: int = 0
- @dataclass
- class HangFeatures:
- """挂载点(有需求词挂在该树上的节点)聚合特征。"""
- category_id: int
- word_count: int = 0
- dims: dict[str, DimStats] = field(default_factory=dict)
- def _dec(value: float, places: int = 8) -> Decimal:
- return Decimal(str(round(float(value), places)))
- def _optional_dec(value: float | None, places: int = 8) -> Decimal | None:
- if value is None:
- return None
- return _dec(value, places)
- def _normalize_parent_id(parent_id: int | None) -> int | None:
- if parent_id is None or parent_id == 0:
- return None
- return parent_id
- def _build_tree_index(
- categories: list[tuple[int, int | None]],
- ) -> tuple[set[int], dict[int | None, list[int]], dict[int, int | None]]:
- """categories: (category_id, parent_id) 列表。"""
- by_id: set[int] = set()
- children: dict[int | None, list[int]] = defaultdict(list)
- parent_of: dict[int, int | None] = {}
- for cid, parent_id in categories:
- by_id.add(cid)
- pid = _normalize_parent_id(parent_id)
- parent_of[cid] = pid
- children[pid].append(cid)
- for kids in children.values():
- kids.sort()
- return by_id, children, parent_of
- def _aggregate_hang_features(
- belong_rows: list[tuple[int, int]],
- stats_by_belong_id: dict[int, Any],
- ) -> dict[int, HangFeatures]:
- """将词级 avg/count 按挂载类目聚合为挂载点 avg/count。"""
- acc: dict[int, dict[str, dict[str, float]]] = defaultdict(
- lambda: {dim: {"wsum": 0.0, "nsum": 0.0} for dim in METRIC_KEYS}
- )
- word_counts: dict[int, int] = defaultdict(int)
- for belong_id, category_id in belong_rows:
- word_counts[category_id] += 1
- stats = stats_by_belong_id.get(belong_id)
- if stats is None:
- continue
- for dim in METRIC_KEYS:
- n = int(getattr(stats, f"{dim}_count") or 0)
- if n <= 0:
- continue
- avg_raw = getattr(stats, f"{dim}_avg", None)
- if avg_raw is None:
- continue
- avg = float(avg_raw)
- cell = acc[category_id][dim]
- cell["wsum"] += avg * n
- cell["nsum"] += n
- features: dict[int, HangFeatures] = {}
- for category_id, wc in word_counts.items():
- dims: dict[str, DimStats] = {}
- for dim in METRIC_KEYS:
- cell = acc[category_id][dim]
- n = int(cell["nsum"])
- if n > 0:
- dims[dim] = DimStats(avg=cell["wsum"] / cell["nsum"], count=n)
- else:
- dims[dim] = DimStats()
- features[category_id] = HangFeatures(
- category_id=category_id,
- word_count=wc,
- dims=dims,
- )
- return features
- def _subtree_hanging_ids(
- node_id: int,
- children: dict[int | None, list[int]],
- hang_ids: set[int],
- cache: dict[int, list[int]],
- ) -> list[int]:
- if node_id in cache:
- return cache[node_id]
- ids: list[int] = []
- if node_id in hang_ids:
- ids.append(node_id)
- for child in children.get(node_id, []):
- ids.extend(_subtree_hanging_ids(child, children, hang_ids, cache))
- cache[node_id] = ids
- return ids
- def _rollup_avg_count(
- by_id: set[int],
- children: dict[int | None, list[int]],
- hangs: dict[int, HangFeatures],
- ) -> dict[int, dict[str, DimStats]]:
- """
- 每个节点取其子树内全部有数据挂载点:
- avg = sum(avg * count) / sum(count),count = sum(count)。
- """
- hang_ids = set(hangs)
- subtree_cache: dict[int, list[int]] = {}
- result: dict[int, dict[str, DimStats]] = {}
- for nid in by_id:
- hanging = _subtree_hanging_ids(nid, children, hang_ids, subtree_cache)
- dim_stats: dict[str, DimStats] = {}
- for dim in METRIC_KEYS:
- wsum = 0.0
- nsum = 0
- for hid in hanging:
- ds = hangs[hid].dims[dim]
- if ds.count <= 0:
- continue
- wsum += ds.avg * ds.count
- nsum += ds.count
- if nsum > 0:
- dim_stats[dim] = DimStats(avg=wsum / nsum, count=nsum)
- else:
- dim_stats[dim] = DimStats()
- result[nid] = dim_stats
- return result
- def _row_from_state(
- nid: int,
- biz_dt: str,
- hung_word_count: int,
- dim_stats: dict[str, DimStats],
- ) -> dict[str, Any]:
- row: dict[str, Any] = {
- "category_id": nid,
- "biz_dt": biz_dt,
- "hung_word_count": hung_word_count,
- }
- for dim in METRIC_KEYS:
- ds = dim_stats[dim]
- row[f"{dim}_count"] = int(ds.count)
- row[f"{dim}_avg"] = _optional_dec(ds.avg) if ds.count > 0 else None
- return row
- def _materialize_stats_row(stats: Any) -> SimpleNamespace:
- """在 session 内抽出标量,避免 DetachedInstanceError。"""
- payload: dict[str, Any] = {"demand_category_id": int(stats.demand_category_id)}
- for dim in METRIC_KEYS:
- count = int(getattr(stats, f"{dim}_count") or 0)
- payload[f"{dim}_count"] = count
- if count > 0:
- avg_raw = getattr(stats, f"{dim}_avg", None)
- payload[f"{dim}_avg"] = float(avg_raw) if avg_raw is not None else None
- else:
- payload[f"{dim}_avg"] = None
- return SimpleNamespace(**payload)
- def _materialize_weight_row(row: Any) -> dict[str, Any]:
- """在 session 内抽出标量,避免 DetachedInstanceError。"""
- payload: dict[str, Any] = {
- "category_id": int(row.category_id),
- "biz_dt": str(row.biz_dt),
- }
- for dim in POP_DIM_KEYS:
- count = int(getattr(row, f"{dim}_count", 0) or 0)
- payload[f"{dim}_count"] = count
- if count > 0:
- avg_raw = getattr(row, f"{dim}_avg", None)
- payload[f"{dim}_avg"] = float(avg_raw) if avg_raw is not None else None
- else:
- payload[f"{dim}_avg"] = None
- return payload
- def _build_rank_score_rows(
- rows: list[dict[str, Any]],
- ) -> list[dict[str, Any]]:
- """根据已写入的权重行计算各节点排名分。"""
- dim_scores: dict[str, dict[int, float]] = {}
- for dim in POP_DIM_KEYS:
- candidates: list[tuple[int, float]] = []
- for row in rows:
- count = int(row[f"{dim}_count"])
- if count <= 0:
- continue
- avg_raw = row[f"{dim}_avg"]
- if avg_raw is None:
- continue
- avg = float(avg_raw)
- candidates.append((int(row["category_id"]), avg))
- dim_scores[dim] = rank_to_scores(candidates)
- updates: list[dict[str, Any]] = []
- for row in rows:
- category_id = int(row["category_id"])
- biz_dt = str(row["biz_dt"])
- score_values = {
- f"{dim}_score": dim_scores[dim].get(category_id)
- for dim in POP_DIM_KEYS
- }
- non_null_scores = [v for v in score_values.values() if v is not None]
- total = sum(non_null_scores) if non_null_scores else None
- updates.append(
- {
- "category_id": category_id,
- "biz_dt": biz_dt,
- **{
- col: _optional_dec(score_values[col])
- for col in (
- "ext_pop_score",
- "plat_sust_pop_score",
- "plat_ly_pop_score",
- "recent_pop_score",
- )
- },
- "total_score": _optional_dec(total),
- }
- )
- return updates
- def _update_category_tree_rank_scores(biz_dt: str) -> dict[str, Any]:
- """在 category_tree_weight 全部写入后,按 biz_dt 更新四维排名分与 total_score。"""
- with get_session() as session:
- repo = CategoryTreeWeightRepository(session)
- rows = [
- _materialize_weight_row(row)
- for row in repo.list_by_biz_dt(biz_dt)
- ]
- if not rows:
- logger.info("Category tree rank scores: no rows, skip biz_dt=%s", biz_dt)
- return {"biz_dt": biz_dt, "nodes": 0, "updated": 0}
- updates = _build_rank_score_rows(rows)
- updated = 0
- with get_session() as session:
- repo = CategoryTreeWeightRepository(session)
- for i in range(0, len(updates), _RANK_UPDATE_BATCH):
- updated += repo.update_rank_scores(updates[i : i + _RANK_UPDATE_BATCH])
- result = {
- "biz_dt": biz_dt,
- "nodes": len(updates),
- "updated": updated,
- }
- logger.info("Category tree rank scores completed: %s", result)
- return result
- def compute_category_tree_weight(biz_dt: str) -> dict[str, Any]:
- """按 biz_dt 计算整棵树各维度加权平均分并写入 category_tree_weight。"""
- with get_session() as session:
- categories = [
- (int(c.id), c.parent_id)
- for c in GlobalTreeCategoryRepository(session).list_active_categories()
- ]
- belong_rows_raw = [
- (int(b.id), int(b.category_id))
- for b in DemandBelongCategoryRepository(session).list_active()
- if b.category_id is not None
- ]
- stats_by_belong = {
- int(s.demand_category_id): _materialize_stats_row(s)
- for s in DemandPopularityStatsRepository(session).list_by_biz_dt(biz_dt)
- }
- if not categories:
- logger.info("Category tree weight: no categories, skip biz_dt=%s", biz_dt)
- return {"biz_dt": biz_dt, "nodes": 0, "hanging": 0, "upserted": 0}
- by_id, children, _parent_of = _build_tree_index(categories)
- belong_rows = [
- (belong_id, category_id)
- for belong_id, category_id in belong_rows_raw
- if category_id in by_id
- ]
- hangs = _aggregate_hang_features(belong_rows, stats_by_belong)
- rolled = _rollup_avg_count(by_id, children, hangs)
- rows: list[dict[str, Any]] = []
- for nid in by_id:
- feat = hangs.get(nid)
- rows.append(
- _row_from_state(
- nid,
- biz_dt,
- feat.word_count if feat else 0,
- rolled[nid],
- )
- )
- upserted = 0
- with get_session() as session:
- repo = CategoryTreeWeightRepository(session)
- for i in range(0, len(rows), _UPSERT_BATCH):
- upserted += repo.upsert_rows(rows[i : i + _UPSERT_BATCH])
- result = {
- "biz_dt": biz_dt,
- "nodes": len(rows),
- "hanging": len(hangs),
- "upserted": upserted,
- }
- logger.info("Category tree weight completed: %s", result)
- rank_stats = _update_category_tree_rank_scores(biz_dt)
- result["rank_scores"] = rank_stats
- return result
|