Explorar o código

feat(渠道效果分析): 品类×时间矩阵支持多指标和品类级别切换

- 品类级别:一级品类/二级品类
- 指标:点击UV、进入分发率、整体裂变率、头部裂变率、推荐裂变率

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
yangxiaohui hai 2 meses
pai
achega
4e974780e5
Modificáronse 1 ficheiros con 69 adicións e 23 borrados
  1. 69 23
      tasks/渠道效果分析/visualize.py

+ 69 - 23
tasks/渠道效果分析/visualize.py

@@ -215,27 +215,60 @@ def calc_channel_categories(channel, date=None):
     ).reset_index()
     return cats.sort_values('点击uv', ascending=False)
 
-# 计算单渠道的品类×时间矩阵
-def calc_channel_category_time(channel):
+# 计算单渠道的品类×时间矩阵(支持一级/二级品类)
+def calc_channel_category_time(channel, level='cat1'):
     """计算单渠道下各品类每天的数据"""
     ch_df = df[df['channel'] == channel]
-    ct = ch_df.groupby(['merge一级品类', 'dt']).apply(
-        lambda x: pd.Series({
-            '点击uv': x['点击uv'].sum(),
-            '整体裂变率': (x['整体裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
-        }), include_groups=False
-    ).reset_index()
-    # 转为矩阵格式:{品类: {日期: 值}}
-    uv_pivot = ct.pivot(index='merge一级品类', columns='dt', values='点击uv').fillna(0)
-    ror_pivot = ct.pivot(index='merge一级品类', columns='dt', values='整体裂变率')
+
+    if level == 'cat1':
+        ct = ch_df.groupby(['merge一级品类', 'dt']).apply(
+            lambda x: pd.Series({
+                '点击uv': x['点击uv'].sum(),
+                '进入分发率': (x['进入分发率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
+                '整体裂变率': (x['整体裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
+                '头部裂变率': (x['头部裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
+                '推荐裂变率': (x['推荐裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
+            }), include_groups=False
+        ).reset_index()
+        cat_col = 'merge一级品类'
+    else:
+        ct = ch_df.groupby(['merge一级品类', 'merge二级品类', 'dt']).apply(
+            lambda x: pd.Series({
+                '点击uv': x['点击uv'].sum(),
+                '进入分发率': (x['进入分发率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
+                '整体裂变率': (x['整体裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
+                '头部裂变率': (x['头部裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
+                '推荐裂变率': (x['推荐裂变率'] * x['点击uv']).sum() / x['点击uv'].sum() if x['点击uv'].sum() > 0 else 0,
+            }), include_groups=False
+        ).reset_index()
+        ct['cat2_label'] = ct.apply(
+            lambda r: f"{str(r['merge一级品类'])}/{str(r['merge二级品类'])}"
+            if pd.notna(r['merge二级品类']) else str(r['merge一级品类']), axis=1
+        )
+        cat_col = 'cat2_label'
+
+    # 转为矩阵格式
+    uv_pivot = ct.pivot(index=cat_col, columns='dt', values='点击uv').fillna(0)
+    rec_pivot = ct.pivot(index=cat_col, columns='dt', values='进入分发率')
+    ror_pivot = ct.pivot(index=cat_col, columns='dt', values='整体裂变率')
+    orig_pivot = ct.pivot(index=cat_col, columns='dt', values='头部裂变率')
+    rec_ror_pivot = ct.pivot(index=cat_col, columns='dt', values='推荐裂变率')
+
     # 按总UV排序
     all_cats = uv_pivot.sum(axis=1).sort_values(ascending=False).index.tolist()
     dates = sorted([int(d) for d in uv_pivot.columns])
+
+    def to_dict(pivot, is_int=False):
+        return {str(cat): {int(d): int(pivot.loc[cat, d]) if is_int else float(pivot.loc[cat, d]) if d in pivot.columns and pd.notna(pivot.loc[cat, d]) else 0 for d in dates} for cat in all_cats}
+
     return {
         'categories': all_cats,
         'dates': dates,
-        'uv': {str(cat): {int(d): int(uv_pivot.loc[cat, d]) if d in uv_pivot.columns else 0 for d in dates} for cat in all_cats},
-        'ror': {str(cat): {int(d): float(ror_pivot.loc[cat, d]) if d in ror_pivot.columns and pd.notna(ror_pivot.loc[cat, d]) else 0 for d in dates} for cat in all_cats},
+        'uv': to_dict(uv_pivot, is_int=True),
+        'rec': to_dict(rec_pivot),
+        'ror': to_dict(ror_pivot),
+        'orig': to_dict(orig_pivot),
+        'rec_ror': to_dict(rec_ror_pivot),
     }
 
 # 计算整体平均数据(用于对比)
@@ -252,7 +285,8 @@ channel_analysis_data = {}
 for ch in main_channel_list:
     trend = calc_channel_daily_trend(ch)
     cats = calc_channel_categories(ch)
-    cat_time = calc_channel_category_time(ch)
+    cat_time_cat1 = calc_channel_category_time(ch, 'cat1')
+    cat_time_cat2 = calc_channel_category_time(ch, 'cat2')
     ch_stats = channel_stats[channel_stats['channel'] == ch].iloc[0]
     # 计算每日品类数据
     daily_cats = {'all': cats.to_dict('records')}
@@ -263,7 +297,7 @@ for ch in main_channel_list:
         'trend': trend.to_dict('records'),
         'categories': cats.to_dict('records'),
         'daily_categories': daily_cats,
-        'category_time': cat_time,
+        'category_time': {'cat1': cat_time_cat1, 'cat2': cat_time_cat2},
         'summary': {
             '点击uv': int(ch_stats['点击uv']),
             '进入分发率': float(ch_stats['进入分发率']),
@@ -890,10 +924,16 @@ html_content = f"""<!DOCTYPE html>
                 <span class="channel-selector-inline">
                     渠道:<select class="channel-select-sync" onchange="syncChannelSelect(this.value)">{channel_options}</select>
                 </span>
-                各品类时间趋势
-                <select id="cat-time-metric" onchange="updateCategoryTimeMatrix()" style="margin-left: 10px; padding: 4px;">
-                    <option value="ror">整体裂变率</option>
+                品类:<select id="cat-time-level" onchange="updateCategoryTimeMatrix()" style="padding: 4px;">
+                    <option value="cat1">一级品类</option>
+                    <option value="cat2">二级品类</option>
+                </select>
+                指标:<select id="cat-time-metric" onchange="updateCategoryTimeMatrix()" style="padding: 4px;">
                     <option value="uv">点击UV</option>
+                    <option value="rec">进入分发率</option>
+                    <option value="ror">整体裂变率</option>
+                    <option value="orig">头部裂变率</option>
+                    <option value="rec_ror">推荐裂变率</option>
                 </select>
                 <button class="reset-btn" onclick="updateCategoryTimeMatrix()">重置</button>
             </div>
@@ -1605,20 +1645,24 @@ html_content = f"""<!DOCTYPE html>
         const channel = document.getElementById('channel-select').value;
         if (!channelData[channel] || !channelData[channel].category_time) return;
 
-        const catTime = channelData[channel].category_time;
+        const level = document.getElementById('cat-time-level').value;
         const metric = document.getElementById('cat-time-metric').value;
+        const catTime = channelData[channel].category_time[level];
+        if (!catTime) return;
+
         const data = catTime[metric];
         const categories = catTime.categories;
         const dates = catTime.dates;
 
         // 生成表头(可点击排序)
         const dateLabels = dates.map(d => String(d).slice(-4));
+        const levelLabel = level === 'cat1' ? '一级品类' : '二级品类';
         document.getElementById('category-time-header').innerHTML = `
-            <tr><th style="cursor:pointer" onclick="sortCatTimeTable(0)">品类</th>${{dateLabels.map((d, i) =>
+            <tr><th style="cursor:pointer" onclick="sortCatTimeTable(0)">${{levelLabel}}</th>${{dateLabels.map((d, i) =>
                 `<th style="cursor:pointer" onclick="sortCatTimeTable(${{i+1}})">${{d}}</th>`).join('')}}</tr>
         `;
 
-        // 计算最大最小值用于渐变
+        // 根据指标设置渐变范围
         let maxVal = 0, minVal = 0;
         categories.forEach(cat => {{
             dates.forEach(dt => {{
@@ -1626,8 +1670,10 @@ html_content = f"""<!DOCTYPE html>
                 if (val > maxVal) maxVal = val;
             }});
         }});
-        if (metric === 'ror') maxVal = Math.max(maxVal, 0.5);
-        if (metric === 'uv') maxVal = Math.max(maxVal, 10000);
+        // 设置最小阈值
+        const thresholds = {{ uv: 10000, rec: 1.0, ror: 0.5, orig: 0.2, rec_ror: 0.3 }};
+        if (metric === 'rec') minVal = 0.6;
+        maxVal = Math.max(maxVal, thresholds[metric] || 0.5);
 
         // 生成数据行(行头可点击排序)
         document.getElementById('category-time-body').innerHTML = categories.map((cat, rowIdx) => {{