小肥柴的Hadoop之旅 快速实验篇(B09)城市维度分析,均匀随机字段的识别与证明
目录
- [1. 从城市偏好分析说起](#1. 从城市偏好分析说起)
- [2. 5 分钟速读](#2. 5 分钟速读)
- [3. 环境准备与数据概览](#3. 环境准备与数据概览)
- [4. 三步探测](#4. 三步探测)
- [4.1 探测一:城市和用户是什么关系](#4.1 探测一:城市和用户是什么关系)
- [4.2 探测二:城市和品类有没有关联](#4.2 探测二:城市和品类有没有关联)
- [4.3 探测三:搜索词能不能定位到城市](#4.3 探测三:搜索词能不能定位到城市)
- [5. 口径设计:份额、品类范围、归一化基准](#5. 口径设计:份额、品类范围、归一化基准)
- [6. 熵:从定义到计算](#6. 熵:从定义到计算)
- [6.1 从生活例子理解熵的直觉](#6.1 从生活例子理解熵的直觉)
- [6.2 熵的数学定义](#6.2 熵的数学定义)
- [6.3 用 B09 的真实数据算一遍](#6.3 用 B09 的真实数据算一遍)
- [6.4 归一化熵](#6.4 归一化熵)
- [6.5 为什么用熵而不是看头号品类](#6.5 为什么用熵而不是看头号品类)
- [6.6 熵在 Hive 里怎么算](#6.6 熵在 Hive 里怎么算)
- [7. 建表](#7. 建表)
- [7.1 ads_city_category_dist 建表](#7.1 ads_city_category_dist 建表)
- [7.2 ads_city_summary 建表](#7.2 ads_city_summary 建表)
- [8. 验证:15 项检查](#8. 验证:15 项检查)
- [9. 核心发现](#9. 核心发现)
- [9.1 发现一:city_id 不是用户属性](#9.1 发现一:city_id 不是用户属性)
- [9.2 发现二:城市和品类完全均匀](#9.2 发现二:城市和品类完全均匀)
- [9.3 发现三:归一化熵全部超过 0.99](#9.3 发现三:归一化熵全部超过 0.99)
- [9.4 发现四:手机异常是全局现象](#9.4 发现四:手机异常是全局现象)
- [9.5 结论](#9.5 结论)
- [10. 对分析方法的意义](#10. 对分析方法的意义)
- [10.1 均匀随机字段识别的三个条件](#10.1 均匀随机字段识别的三个条件)
- [10.2 熵作为通用工具的迁移方式](#10.2 熵作为通用工具的迁移方式)
- [11. 对后续实验的影响](#11. 对后续实验的影响)
- [12. 工程踩坑](#12. 工程踩坑)
- [13. 写在最后](#13. 写在最后)
- [14. 附录:完整脚本](#14. 附录:完整脚本)
1. 从城市偏好分析说起
B09 原本的使命是做城市和区域品类偏好分析。这类分析在真实电商里很常见,比如一线城市可能更爱买数码产品,三四线城市可能更爱买日用百货,通过地域差异来做精细化运营。
但动手之前,按照 B01 到 B08 一直坚持的习惯,先做探测,再决定走向。这个习惯在 B08 已经证明过价值。当时 B08 用十一段探测推翻了最初对搜索词的设想,省下大量无效工作。B09 沿用同样的策略,先用三段探测把数据摸清楚。
探测的结果出乎意料,也直接改变了 B09 的走向;这篇报告记录从探测到建表再到验证的完整过程,以及最后得到的结论。
2. 5 分钟速读
- 原始设想:城市 × 品类偏好分析,找出不同城市的偏好差异。
- 探测结论:26 个城市,每个城市都覆盖全部 100 用户。city_id 是行为级随机字段,与用户无关。品类分布完全均匀,无城市偏好。
- 改用方案:不再找偏好,用熵把无偏好量化,把"无偏好"这个结论做实。
- 核心方法论:当分析维度满足三个条件时判定为均匀随机字段,user_cnt 恒定、归一化熵 ≥ 0.99、最大份额 ≤ 品类数倒数的 1.2 倍。
- 主要发现:三套转化率的归一化熵全部 ≥ 0.99,最大品类份额无一超过 0.06。
- 可复用产出 :
ads_city_category_dist+ads_city_summary+ 15 项验证。
3. 环境准备与数据概览
所有操作都在 /home/hadoop/b09 目录下进行。先建目录结构:
bash
mkdir -p /home/hadoop/b09/{sql,output,docs}
cd /home/hadoop/b09
启动集群(如果没启):
bash
start-dfs.sh && start-yarn.sh
检查 Hive 能正常连上:
bash
hive -e "USE b_group; SHOW TABLES;" 2>&1 | tail -20
应该能看到 dwd_user_behavior 以及 B01 到 B08 留下的其他表。
每次写 HQL 脚本,开头必须带这一段 SET 参数。这是前面几个实验踩了 OOM 之后定下来的内存控制配置,B09 及以后都继承:
sql
SET hive.exec.mode.local.auto=false;
SET mapreduce.task.io.sort.mb=32;
SET mapreduce.map.memory.mb=512;
SET mapreduce.map.java.opts=-Xmx384m;
SET mapreduce.reduce.memory.mb=768;
SET mapreduce.reduce.java.opts=-Xmx512m;
SET hive.exec.reducers.max=1;
另外客户端堆也要设,写进 ~/.bashrc:
bash
export HADOOP_CLIENT_OPTS="-Xmx512m -Xms256m"
不设的话,Hive CLI 本身可能 OOM。
这份数据是电商用户行为日志,180570 行,覆盖 11 天,从 2019-07-17 到 2019-07-27。每一行是一次用户行为。分析口径与 B01 到 B08 保持一致,全部排除 07-27,用 WHERE dt < '2019-07-27' 物理过滤。
DWD 表 dwd_user_behavior 里 B09 会用到的字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| user_id | bigint | 用户 ID |
| session_id | string | 会话 ID |
| city_id | bigint | 城市 ID |
| behavior_type | string | click / order / pay / search |
| search_keyword | string | 搜索词 |
| click_category_id | bigint | 点击品类 ID |
| order_category_ids | array<string> | 下单品类 ID 列表 |
| pay_category_ids | array<string> | 支付品类 ID 列表 |
| seq_in_session | bigint | 行为在 session 内的序号 |
| dt | string | 分区日期 |
注意 city_id 是 bigint,order_category_ids 和 pay_category_ids 是数组类型,这两个字段的差异会直接影响后续 SQL 的写法。
4. 三步探测
在写任何 ADS 表之前,先跑三组探测,把"能做什么"和"怎么做"钉死。
4.1 探测一:城市和用户是什么关系
第一个问题很基础,数据里到底有多少个城市,每个城市有多少用户。这个问题看起来简单,但答案决定了后续所有分析有没有意义。
bash
cd /home/hadoop/b09 && hive -e "
SET hive.exec.mode.local.auto=false;
SET mapreduce.task.io.sort.mb=32;
SET mapreduce.map.memory.mb=512;
SET mapreduce.map.java.opts=-Xmx384m;
SET mapreduce.reduce.memory.mb=768;
SET mapreduce.reduce.java.opts=-Xmx512m;
SET hive.exec.reducers.max=1;
USE b_group;
SELECT 'P01_city_cnt' AS probe_tag, COUNT(DISTINCT city_id) AS val
FROM dwd_user_behavior
WHERE dt < '2019-07-27';
SELECT city_id,
COUNT(DISTINCT user_id) AS user_cnt,
COUNT(DISTINCT session_id) AS session_cnt,
COUNT(*) AS behavior_cnt
FROM dwd_user_behavior
WHERE dt < '2019-07-27'
GROUP BY city_id
ORDER BY city_id;
" 2>&1 | tee output/probe1.log
预期输出(截取部分):
P01_city_cnt 26
1 100 3759 6296
2 100 3873 6489
3 100 3822 6443
...
25 100 3811 6481
26 100 3797 6403
跑出来的结果让整个 B09 的方向彻底改变。二十六个城市,每一个城市的 user_cnt 都是 100。也就是说,全部一百个用户同时出现在全部二十六个城市里。
这在真实电商场景里不可能发生。一个用户的所在城市是相对固定的属性,不可能今天在北京明天在广州,更不可能同时属于二十六个城市。唯一合理的解释是 city_id 这个字段不是用户属性,而是行为级别的随机分配字段。每条行为记录随机分配一个城市编号,和用户本身没有绑定关系。
这个发现非常关键。如果 city_id 和 user 没有绑定关系,那么所谓城市偏好分析就失去了对象。因为城市并不是一群固定用户的集合,只是一堆随机贴了标签的行为记录。
4.2 探测二:城市和品类有没有关联
第二个问题是在第一个问题的基础上继续追问。既然 city_id 是行为级别的随机字段,那它在品类维度上有没有呈现出任何非随机的特征。
这一步要处理点击、下单、支付三个维度。点击品类在表里是单值字段 click_category_id,直接分组就行。下单和支付品类在表里是数组字段 order_category_ids 和 pay_category_ids,需要用展开数组的语法把它们一行拆成多行。
bash
cd /home/hadoop/b09 && hive -e "
SET hive.exec.mode.local.auto=false;
SET mapreduce.task.io.sort.mb=32;
SET mapreduce.map.memory.mb=512;
SET mapreduce.map.java.opts=-Xmx384m;
SET mapreduce.reduce.memory.mb=768;
SET mapreduce.reduce.java.opts=-Xmx512m;
SET hive.exec.reducers.max=1;
USE b_group;
SELECT 'P03' AS probe_tag, city_id, click_category_id AS cate_id, COUNT(*) AS cnt
FROM dwd_user_behavior
WHERE dt < '2019-07-27'
AND click_category_id IS NOT NULL
GROUP BY city_id, click_category_id
ORDER BY city_id, cnt DESC;
SELECT 'P04' AS probe_tag, t.city_id, oc.cate_id, COUNT(*) AS cnt
FROM dwd_user_behavior t
LATERAL VIEW explode(order_category_ids) oc AS cate_id
WHERE t.dt < '2019-07-27'
AND oc.cate_id IS NOT NULL AND oc.cate_id != ''
GROUP BY t.city_id, oc.cate_id
ORDER BY t.city_id, cnt DESC;
SELECT 'P05' AS probe_tag, t.city_id, pc.cate_id, COUNT(*) AS cnt
FROM dwd_user_behavior t
LATERAL VIEW explode(pay_category_ids) pc AS cate_id
WHERE t.dt < '2019-07-27'
AND pc.cate_id IS NOT NULL AND pc.cate_id != ''
GROUP BY t.city_id, pc.cate_id
ORDER BY t.city_id, cnt DESC;
" 2>&1 | tee output/probe2.log
预期输出(截取部分):
P03 1 11 229
P03 1 5 225
P03 1 8 224
P03 1 14 221
...
P04 1 17 77
P04 1 3 72
...
P05 1 3 46
P05 1 2 46
...
三个维度跑下来,每个都是五百二十行。二十六乘二十正好等于五百二十,说明每个城市都覆盖全部二十个品类,没有哪个城市缺品类。
再细看数值分布。点击维度里每个格子的计数落在 178 到 257 之间,下单维度落在 23 到 83 之间,支付维度落在 23 到 64 之间。同一维度内每个格子围绕均值的波动幅度大约在正负百分之十五以内。下单和支付因为样本量更小,波动比例会大一些,但整体仍然是随机的样子。
还有一个观察更能说明问题。把每个城市的头号品类挑出来,二十六座城市选出的头号品类分散在十八个不同的品类上。如果城市真的有偏好,头号品类应该会扎堆,比如大家都爱买手机,那么手机就会反复出现在头号位置上。现在的分散状态,就是没有偏好的直接证据。
4.3 探测三:搜索词能不能定位到城市
第三个问题来自 B08 的遗留。B08 发现手机这个词的搜索量只有其他五个词的四成左右,而且点击转化率高达百分之七十一,明显偏高。当时没法判断这是全局现象还是某个特定城市造成的异常。B09 正好有城市维度,可以追一下。
bash
cd /home/hadoop/b09 && hive -e "
SET hive.exec.mode.local.auto=false;
SET mapreduce.task.io.sort.mb=32;
SET mapreduce.map.memory.mb=512;
SET mapreduce.map.java.opts=-Xmx384m;
SET mapreduce.reduce.memory.mb=768;
SET mapreduce.reduce.java.opts=-Xmx512m;
SET hive.exec.reducers.max=1;
USE b_group;
SELECT 'P06' AS probe_tag, city_id, search_keyword, COUNT(*) AS cnt
FROM dwd_user_behavior
WHERE dt < '2019-07-27'
AND search_keyword IS NOT NULL AND search_keyword != ''
GROUP BY city_id, search_keyword
ORDER BY city_id, cnt DESC;
SELECT 'P07' AS probe_tag, city_id, COUNT(*) AS phone_search_cnt
FROM dwd_user_behavior
WHERE dt < '2019-07-27'
AND search_keyword = '手机'
GROUP BY city_id
ORDER BY phone_search_cnt DESC;
" 2>&1 | tee output/probe3.log
预期输出(P07 完整):
P07 6 135
P07 4 126
P07 22 124
P07 16 124
P07 10 122
P07 15 121
P07 3 121
P07 17 117
P07 14 115
P07 8 114
P07 2 114
P07 9 109
P07 21 108
P07 5 108
P07 19 106
P07 25 106
P07 26 105
P07 1 103
P07 11 102
P07 12 102
P07 18 102
P07 24 101
P07 20 99
P07 7 91
P07 23 87
P07 13 84
手机搜索量在城市间的分布是 84 到 135,均值大约一百一十。波动幅度大约正负百分之二十,符合随机泊松噪声的样子。没有任何城市跳出来,也没有任何城市明显偏低。B08 发现的手机异常是全局现象,跟城市没有关系。
三步探测走完,B09 的走向已经确定。原本设想的城市偏好分析在这个数据集上做不出结果,因为 city_id 本身就不携带任何有意义的信息。接下来要做的不是强行找偏好,而是用更严谨的方式把无偏好这个结论量化出来。
5. 口径设计:份额、品类范围、归一化基准
探测已经把数据摸清楚了。在动手建表之前,有三个口径必须先钉死,否则算出来的熵值没有可比性。
第一,份额的分母是什么。
ads_city_category_dist 里每一行是一个城市和一个品类的组合,需要算这个品类在该城市内的占比。分母是该城市在该维度上的总次数。比如城市 1 的点击份额,分母就是城市 1 在分析范围内的全部点击次数。
这个选择在代码里通过窗口函数实现,SUM(SUM(click_flag)) OVER (PARTITION BY city_id) 这一步先按 city 和品类分组求和,再按 city 分组求总和,作为分母。
第二,品类范围要不要做 TopN 过滤。
不需要。B04 发现品类分布均匀,B09 的探测也确认二十个品类全部出现。如果做 TopN 过滤,不同城市保留的品类可能不一样,熵的归一化基准就不一致,26 个城市没法横向比。
所以保留全部 20 个品类,每个城市都是 26 乘 20 的满矩阵。这也是为什么 ads_city_category_dist 是 520 行而不是更少。
第三,熵的归一化基准用多少。
用 ln(20),不是 ln(该城市实际出现的品类数)。B09 的场景里每个城市都覆盖全部 20 个品类,用 ln(20) 作为归一化基准,26 个城市的熵直接可比。
如果某个城市缺品类,那么"完全均匀"的上限就不再是 ln(20),而是 ln(实际品类数)。B09 的场景不需要处理这种情况,但这个口径要在报告里写清楚,方便迁移到别的场景时参考。
口径定完,接下来讲清楚熵到底是怎么算的。
6. 熵:从定义到计算
要把无偏好这个结论量化,需要一个能衡量分布集中程度的指标。熵就是干这个用的。
6.1 从生活例子理解熵的直觉
假设有两个班级,各四十人。A 班四十个人全部喜欢数学,问他们喜欢什么科目,四十个答案完全一样。B 班四十个人,喜欢数学、语文、英语、物理、化学的各八人,答案非常分散。
现在问哪个班的科目多样性更高。答案很明显是 B 班。A 班毫无多样性,B 班很分散。
熵就是用来量化这个多样性的数字。A 班的熵等于零,因为只有一种选择。B 班的熵比较大,因为有五种选择均匀分布。
6.2 熵的数学定义
熵的严格定义是这样的。假设一个分布有 N 个类别,第 i 个类别的占比是 p_i,所有 p_i 加总等于 1。熵的计算公式是:
H = -Σ(p_i × ln(p_i)) i = 1, 2, ..., N
这个公式里 ln 是自然对数,Σ 是对所有类别求和。公式本身不长,但每一项的含义值得拆开看。p_i 是该类别的占比,ln(p_i) 是占比的自然对数。占比越小,ln(p_i) 的绝对值越大,但因为前面乘了一个很小的 p_i,乘起来之后贡献反而变小。占比越大,ln(p_i) 的绝对值越小,但乘的 p_i 大,乘起来之后贡献变大。两者存在一个平衡点,在 p_i 等于 1/e 约等于 0.368 附近取得极大值。
熵有一个关键性质,当 N 个类别完全均匀分布时,也就是每个 p_i 都等于 1/N,熵取到最大值,这个最大值就是 ln(N)。
6.3 用 B09 的真实数据算一遍
拿 B09 的一个城市做例子。假设某个城市点击品类分布如下,为方便手算先简化成四个品类:
| 品类 | 点击次数 | 占比 p | p × ln(1/p) |
|---|---|---|---|
| 11 | 100 | 0.50 | 0.50 × 0.693 = 0.347 |
| 5 | 60 | 0.30 | 0.30 × 1.204 = 0.361 |
| 8 | 30 | 0.15 | 0.15 × 1.897 = 0.285 |
| 14 | 10 | 0.05 | 0.05 × 2.996 = 0.150 |
| 合计 | 200 | 1.00 | 熵 H = 1.143 |
如果四个品类完全均匀分布,每个品类五十次,占比都是 0.25,那么:
| 品类 | 占比 p | p × ln(1/p) |
|---|---|---|
| 四个品类 | 各 0.25 | 4 × 0.25 × 1.386 = 1.386 |
这个例子说明两件事。完全均匀时熵等于 ln(4) 约等于 1.386,是最大值。上面那个偏斜的分布算出熵等于 1.143,比最大值小,说明存在一定偏好。
B09 的场景有二十个品类,最大值是 ln(20) 约等于 2.996。归一化之后,就是用实际熵除以这个最大值。
6.4 归一化熵
不同分析场景里品类数量可能不一样。B09 是二十个品类,如果换个场景是五个品类,直接比较熵的绝对值就不直观。所以做一步归一化处理。
归一化熵等于实际熵除以最大熵。在 B09 里最大熵是 ln(20) 约等于 2.996。归一化之后熵落在零到一之间。
归一化熵等于一点零,说明完全均匀,毫无偏好。归一化熵等于零点零,说明完全集中,偏好极强。归一化熵等于零点八,说明比较分散。归一化熵等于零点三,说明比较集中。
B09 预设的判读标准是这样的。归一化熵大于等于零点九九,判定为完全均匀,无偏好。落在零点九五到零点九九之间,判定为偏好极弱。落在零点八零到零点九五之间,判定为存在一定偏好。低于零点八零,判定为偏好明显。
6.5 为什么用熵而不是看头号品类
有人可能会问,直接看每个城市的头号品类是哪个不就行了吗,为什么要绕这么大一圈算熵。
原因有三个。
第一,头号品类会骗人。二十个品类里第一名占百分之五点三,第二名占百分之五点二,差距只有零点一个百分点。这时候说头号品类是品类十一,其实没有意义,因为品类十一和品类十二在统计上没有区别。
第二,波动和偏好分不清。探测阶段看到每个城市的点击量在 178 到 257 之间波动,这个波动到底是随机噪声还是真实偏好,光看数值看不出来。熵把整个分布压缩成一个零到一的数字,可以直接比较。
第三,可以横向比。把二十六个城市的熵放在一起,一眼就能看出有没有哪个城市掉队。如果二十六个城市的归一化熵都在零点九九以上,那就一锤定音,城市维度没有偏好。
6.6 熵在 Hive 里怎么算
在 Hive 里计算熵,核心是一行聚合表达式。
sql
SUM(CASE WHEN click_share > 0 THEN -click_share * LN(click_share) ELSE 0 END)
这里需要解释一下 CASE 的作用。当某个城市某个品类的占比为零时,零乘以零的自然对数在数学上没有定义,Hive 会返回 NULL,污染整个城市的熵值。所以用 CASE 语句把占比为零的情况单独处理,贡献记为零。
完整的归一化熵计算表达式是这样的:
sql
-- 实际熵
SUM(CASE WHEN click_share > 0 THEN -click_share * LN(click_share) ELSE 0 END) AS click_entropy
-- 归一化熵
SUM(CASE WHEN click_share > 0 THEN -click_share * LN(click_share) ELSE 0 END) / LN(20) AS click_entropy_norm
7. 建表
探测和口径都定下来了,开始建表。B09 产出两张 ADS 表,ads_city_category_dist 记录城市和品类的交叉分布,ads_city_summary 记录每个城市的汇总指标。
7.1 ads_city_category_dist 建表
把下面的内容保存为 sql/01_build_city_category_dist.hql:
sql
SET hive.exec.mode.local.auto=false;
SET mapreduce.task.io.sort.mb=32;
SET mapreduce.map.memory.mb=512;
SET mapreduce.map.java.opts=-Xmx384m;
SET mapreduce.reduce.memory.mb=768;
SET mapreduce.reduce.java.opts=-Xmx512m;
SET hive.exec.reducers.max=1;
USE b_group;
DROP TABLE IF EXISTS ads_city_category_dist;
CREATE TABLE ads_city_category_dist (
city_id bigint,
cate_id bigint,
click_cnt bigint,
order_cnt bigint,
pay_cnt bigint,
click_share double,
order_share double,
pay_share double
) STORED AS ORC;
INSERT OVERWRITE TABLE ads_city_category_dist
SELECT
t.city_id,
t.cate_id,
SUM(t.click_flag) AS click_cnt,
SUM(t.order_flag) AS order_cnt,
SUM(t.pay_flag) AS pay_cnt,
SUM(t.click_flag) / SUM(SUM(t.click_flag)) OVER (PARTITION BY t.city_id) AS click_share,
SUM(t.order_flag) / SUM(SUM(t.order_flag)) OVER (PARTITION BY t.city_id) AS order_share,
SUM(t.pay_flag) / SUM(SUM(t.pay_flag)) OVER (PARTITION BY t.city_id) AS pay_share
FROM (
SELECT city_id, CAST(click_category_id AS bigint) AS cate_id,
1 AS click_flag, 0 AS order_flag, 0 AS pay_flag
FROM dwd_user_behavior
WHERE dt < '2019-07-27' AND click_category_id IS NOT NULL
UNION ALL
SELECT t.city_id, CAST(oc.cate_id AS bigint),
0, 1, 0
FROM dwd_user_behavior t
LATERAL VIEW explode(order_category_ids) oc AS cate_id
WHERE t.dt < '2019-07-27'
AND oc.cate_id IS NOT NULL AND oc.cate_id != ''
UNION ALL
SELECT t.city_id, CAST(pc.cate_id AS bigint),
0, 0, 1
FROM dwd_user_behavior t
LATERAL VIEW explode(pay_category_ids) pc AS cate_id
WHERE t.dt < '2019-07-27'
AND pc.cate_id IS NOT NULL AND pc.cate_id != ''
) t
GROUP BY t.city_id, t.cate_id;
执行:
bash
cd /home/hadoop/b09 && hive -f sql/01_build_city_category_dist.hql 2>&1 | tee output/01_build_city_category_dist.log
跑完看日志末尾有没有 OK 和 Time taken。
查看结果:
bash
cd /home/hadoop/b09 && hive -e "
SET hive.exec.mode.local.auto=false;
SET mapreduce.task.io.sort.mb=32;
SET mapreduce.map.memory.mb=512;
SET mapreduce.map.java.opts=-Xmx384m;
SET mapreduce.reduce.memory.mb=768;
SET mapreduce.reduce.java.opts=-Xmx512m;
SET hive.exec.reducers.max=1;
USE b_group;
SELECT * FROM ads_city_category_dist LIMIT 20;
" 2>&1 | tee output/01_view_dist.log
7.2 ads_city_summary 建表
把下面的内容保存为 sql/02_build_city_summary.hql:
sql
SET hive.exec.mode.local.auto=false;
SET mapreduce.task.io.sort.mb=32;
SET mapreduce.map.memory.mb=512;
SET mapreduce.map.java.opts=-Xmx384m;
SET mapreduce.reduce.memory.mb=768;
SET mapreduce.reduce.java.opts=-Xmx512m;
SET hive.exec.reducers.max=1;
USE b_group;
DROP TABLE IF EXISTS ads_city_summary;
CREATE TABLE ads_city_summary (
city_id bigint,
total_click bigint,
total_order bigint,
total_pay bigint,
click_entropy double,
click_entropy_norm double,
order_entropy_norm double,
pay_entropy_norm double,
top_click_cate bigint,
max_click_share double
) STORED AS ORC;
WITH city_agg AS (
SELECT
city_id,
SUM(click_cnt) AS total_click,
SUM(order_cnt) AS total_order,
SUM(pay_cnt) AS total_pay,
SUM(CASE WHEN click_share > 0 THEN -click_share * LN(click_share) ELSE 0 END) AS click_entropy,
SUM(CASE WHEN order_share > 0 THEN -order_share * LN(order_share) ELSE 0 END) AS order_entropy,
SUM(CASE WHEN pay_share > 0 THEN -pay_share * LN(pay_share) ELSE 0 END) AS pay_entropy,
MAX(click_share) AS max_click_share
FROM ads_city_category_dist
GROUP BY city_id
),
city_top AS (
SELECT city_id, cate_id AS top_click_cate
FROM (
SELECT city_id, cate_id, click_share,
ROW_NUMBER() OVER (PARTITION BY city_id ORDER BY click_share DESC, cate_id ASC) AS rn
FROM ads_city_category_dist
) x
WHERE x.rn = 1
)
INSERT OVERWRITE TABLE ads_city_summary
SELECT
a.city_id,
a.total_click,
a.total_order,
a.total_pay,
a.click_entropy,
a.click_entropy / LN(20) AS click_entropy_norm,
a.order_entropy / LN(20) AS order_entropy_norm,
a.pay_entropy / LN(20) AS pay_entropy_norm,
t.top_click_cate,
a.max_click_share
FROM city_agg a
JOIN city_top t ON a.city_id = t.city_id;
注意 Hive 的一个语法坑 :WITH 必须在 INSERT 之前 ,不能写成 INSERT ... WITH ...。这跟标准 SQL 的习惯相反,写成后者会报 ParseException。
执行:
bash
cd /home/hadoop/b09 && hive -f sql/02_build_city_summary.hql 2>&1 | tee output/02_build_city_summary.log
查看结果:
bash
cd /home/hadoop/b09 && hive -e "
SET hive.exec.mode.local.auto=false;
SET mapreduce.task.io.sort.mb=32;
SET mapreduce.map.memory.mb=512;
SET mapreduce.map.java.opts=-Xmx384m;
SET mapreduce.reduce.memory.mb=768;
SET mapreduce.reduce.java.opts=-Xmx512m;
SET hive.exec.reducers.max=1;
USE b_group;
SELECT * FROM ads_city_summary ORDER BY city_id;
" 2>&1 | tee output/02_view_summary.log
8. 验证:15 项检查
数据交付前跑 15 项验证。保存为 sql/03_verify_all.hql:
sql
SET hive.exec.mode.local.auto=false;
SET mapreduce.task.io.sort.mb=32;
SET mapreduce.map.memory.mb=512;
SET mapreduce.map.java.opts=-Xmx384m;
SET mapreduce.reduce.memory.mb=768;
SET mapreduce.reduce.java.opts=-Xmx512m;
SET hive.exec.reducers.max=1;
USE b_group;
SELECT 'V01_dist_row_cnt' AS tag, COUNT(*) AS val FROM ads_city_category_dist;
SELECT 'V02_dist_city_cnt' AS tag, COUNT(DISTINCT city_id) AS val FROM ads_city_category_dist;
SELECT 'V03_dist_cate_cnt' AS tag, COUNT(DISTINCT cate_id) AS val FROM ads_city_category_dist;
SELECT 'V04_click_share_sum' AS tag,
MIN(s) AS min_v, MAX(s) AS max_v
FROM (SELECT city_id, SUM(click_share) AS s FROM ads_city_category_dist GROUP BY city_id) x;
SELECT 'V05_order_share_sum' AS tag,
MIN(s) AS min_v, MAX(s) AS max_v
FROM (SELECT city_id, SUM(order_share) AS s FROM ads_city_category_dist GROUP BY city_id) x;
SELECT 'V06_pay_share_sum' AS tag,
MIN(s) AS min_v, MAX(s) AS max_v
FROM (SELECT city_id, SUM(pay_share) AS s FROM ads_city_category_dist GROUP BY city_id) x;
SELECT 'V07_summary_row_cnt' AS tag, COUNT(*) AS val FROM ads_city_summary;
SELECT 'V08_summary_null_cnt' AS tag, COUNT(*) AS val
FROM ads_city_summary
WHERE click_entropy_norm IS NULL
OR order_entropy_norm IS NULL
OR pay_entropy_norm IS NULL;
SELECT 'V09_click_entropy_ge_099' AS tag,
SUM(CASE WHEN click_entropy_norm >= 0.99 THEN 1 ELSE 0 END) AS pass_cnt,
COUNT(*) AS total_cnt
FROM ads_city_summary;
SELECT 'V10_order_entropy_ge_099' AS tag,
SUM(CASE WHEN order_entropy_norm >= 0.99 THEN 1 ELSE 0 END) AS pass_cnt,
COUNT(*) AS total_cnt
FROM ads_city_summary;
SELECT 'V11_pay_entropy_ge_099' AS tag,
SUM(CASE WHEN pay_entropy_norm >= 0.99 THEN 1 ELSE 0 END) AS pass_cnt,
COUNT(*) AS total_cnt
FROM ads_city_summary;
SELECT 'V12_entropy_norm_range' AS tag,
MIN(click_entropy_norm) AS min_c,
MAX(click_entropy_norm) AS max_c,
MIN(order_entropy_norm) AS min_o,
MAX(order_entropy_norm) AS max_o,
MIN(pay_entropy_norm) AS min_p,
MAX(pay_entropy_norm) AS max_p
FROM ads_city_summary;
SELECT 'V13_max_click_share_le_006' AS tag,
SUM(CASE WHEN max_click_share <= 0.06 THEN 1 ELSE 0 END) AS pass_cnt,
COUNT(*) AS total_cnt,
MAX(max_click_share) AS global_max
FROM ads_city_summary;
SELECT 'V14_top_cate_distinct_cnt' AS tag,
COUNT(DISTINCT top_click_cate) AS val
FROM ads_city_summary;
SELECT 'V15_summary_vs_dist_click' AS tag,
s_sum AS summary_sum,
d_sum AS dist_sum
FROM
(SELECT SUM(total_click) AS s_sum FROM ads_city_summary) a,
(SELECT SUM(click_cnt) AS d_sum FROM ads_city_category_dist) b;
执行:
bash
cd /home/hadoop/b09 && hive -f sql/03_verify_all.hql 2>&1 | tee output/03_verify_all.log
实测输出:
V01_dist_row_cnt 520
V02_dist_city_cnt 26
V03_dist_cate_cnt 20
V04_click_share_sum 0.9999999999999999 1.0000000000000002
V05_order_share_sum 0.9999999999999997 1.0000000000000002
V06_pay_share_sum 0.9999999999999998 1.0000000000000002
V07_summary_row_cnt 26
V08_summary_null_cnt 0
V09_click_entropy_ge_099 26 26
V10_order_entropy_ge_099 26 26
V11_pay_entropy_ge_099 26 26
V12_entropy_norm_range 0.998861530494143 0.9996171078041781 0.9968867765247956 0.9989095663801342 0.9945617397099424 0.998238390289534
V13_max_click_share_le_006 26 26 0.059271217712177124
V14_top_cate_distinct_cnt 18
V15_summary_vs_dist_click 111310 111310
十五项全部通过。三套熵之间有一个细微的梯度值得解释。点击熵最高,下单熵次之,支付熵最低。这是样本量递减的正常效应。点击总数大约十一万,下单大约一万八,支付大约一万三。样本越小,随机波动占比越高,归一化熵越容易略微低于 1。但即使支付维度的最低值 0.9946,也远高于 0.99 的阈值。
9. 核心发现
9.1 发现一:city_id 不是用户属性
探测一的输出直接说明问题:
1 100 3759 6296
2 100 3873 6489
3 100 3822 6443
...
25 100 3811 6481
26 100 3797 6403
二十六个城市,每一个城市的 user_cnt 恒等于 100。全部一百个用户同时出现在全部二十六个城市里。这个现象在真实电商中不可能出现,只能说明 city_id 是行为级别的随机分配字段。这个发现直接决定了 B09 的走向,因为如果城市和用户没有绑定关系,城市偏好分析就失去了对象。
9.2 发现二:城市和品类完全均匀
ads_city_category_dist 里 520 个格子的 click_share 全部落在 0.0413 到 0.0593 之间,均值精确等于 0.0500。V05 的输出显示每个城市的最大品类份额:
V05 1 0.05483716475095785
V05 2 0.059271217712177124
V05 3 0.058360352014821676
V05 4 0.05748241571671113
V05 5 0.05708893606982779
V05 6 0.05442826450226784
V05 7 0.05926098071113177
V05 8 0.05757715338553662
V05 9 0.05620335820895522
V05 10 0.05632790028763183
V05 11 0.05659059152305543
V05 12 0.05823863636363636
V05 13 0.05827452765763715
V05 14 0.05462962962962963
V05 15 0.05405405405405406
V05 16 0.059192200557103065
V05 17 0.0574792243767313
V05 18 0.05487664284067328
V05 19 0.05588166236205682
V05 20 0.05572116487044256
V05 21 0.05572394497551877
V05 22 0.053174045443897866
V05 23 0.056653177814806
V05 24 0.0543757292882147
V05 25 0.05701754385964912
V05 26 0.056417489421720736
二十六座城市,最大品类份额全部在 0.053 到 0.059 之间,无一超过 0.06。二十六个城市的头号品类去重数量是 18,散布在十八个不同品类上。这些数字放在一起说明品类分布是数学上的均匀分布。
9.3 发现三:归一化熵全部超过 0.99
V08 的完整输出如下,26 行全部列出:
city_id click_norm order_norm pay_norm top_cate max_share
1 0.9994 0.9985 0.9982 11 0.0548
2 0.9992 0.9976 0.9981 18 0.0593
3 0.9989 0.9978 0.9980 14 0.0584
4 0.9994 0.9981 0.9961 9 0.0575
5 0.9994 0.9988 0.9961 12 0.0571
6 0.9995 0.9977 0.9965 1 0.0544
7 0.9990 0.9983 0.9956 1 0.0593
8 0.9989 0.9975 0.9968 8 0.0576
9 0.9994 0.9971 0.9976 4 0.0562
10 0.9996 0.9969 0.9973 5 0.0563
11 0.9992 0.9989 0.9965 17 0.0566
12 0.9991 0.9977 0.9952 2 0.0582
13 0.9989 0.9981 0.9977 7 0.0583
14 0.9994 0.9973 0.9946 2 0.0546
15 0.9995 0.9983 0.9980 15 0.0541
16 0.9991 0.9979 0.9969 13 0.0592
17 0.9993 0.9986 0.9982 14 0.0575
18 0.9995 0.9969 0.9952 19 0.0549
19 0.9995 0.9978 0.9969 20 0.0559
20 0.9994 0.9979 0.9979 16 0.0557
21 0.9995 0.9977 0.9956 6 0.0557
22 0.9996 0.9978 0.9979 9 0.0532
23 0.9993 0.9971 0.9967 7 0.0567
24 0.9996 0.9986 0.9970 6 0.0544
25 0.9993 0.9978 0.9976 15 0.0570
26 0.9994 0.9985 0.9981 15 0.0564
三套归一化熵的全局范围:
click_entropy_norm: 0.9989 ~ 0.9996
order_entropy_norm: 0.9969 ~ 0.9989
pay_entropy_norm: 0.9946 ~ 0.9982
全部二十六座城市、全部三个维度都超过 0.99。这意味着每个城市的品类分布与完全均匀之间的差异不到百分之一。这是城市维度不可分的最强量化证据。
9.4 发现四:手机异常是全局现象
B08 发现手机搜索量只有其他五个词的四成,点击转化率高达百分之七十一。B09 的城市维度追踪输出显示:
P07 6 135
P07 4 126
P07 22 124
P07 16 124
P07 10 122
P07 15 121
P07 3 121
P07 17 117
P07 14 115
P07 8 114
P07 2 114
P07 9 109
P07 21 108
P07 5 108
P07 19 106
P07 25 106
P07 26 105
P07 1 103
P07 11 102
P07 12 102
P07 18 102
P07 24 101
P07 20 99
P07 7 91
P07 23 87
P07 13 84
手机搜索量在城市间的分布是 84 到 135,均值大约一百一十。波动幅度大约正负百分之二十,符合随机泊松噪声的样子。没有任何城市跳出来,也没有任何城市明显偏低。B08 发现的手机异常是全局现象,跟城市没有关系。
9.5 结论
在一百个用户的样本下,城市维度不存在任何可分析的品类偏好。B09 的产出价值不在于发现偏好,而在于三件事。第一,用熵给出无偏好的量化标准,可以复用到后续任何新维度上。第二,证明 city_id 是均匀随机字段,为后续实验排除城市维度。第三,建立了一套识别均匀随机字段的方法论模板。
10. 对分析方法的意义
10.1 均匀随机字段识别的三个条件
B09 摸出来一套判断方法。当分析维度 X 满足下面三个条件时,可以判定 X 是均匀随机字段。
条件一,X 的每个取值都覆盖全部用户,也就是每个 X 取值下的 user_cnt 恒定。B09 里 26 个城市的 user_cnt 全是 100,这是最强的信号。
条件二,每个 X 取值内的目标分布归一化熵大于等于 0.99。B09 里三套转化率的归一化熵全部 ≥ 0.99。
条件三,每个 X 取值的最大份额小于等于品类数的倒数的 1.2 倍。B09 里最大份额 0.0593,品类数倒数是 0.05,0.05 乘 1.2 等于 0.06,实测值低于这个阈值。
三个条件同时满足,就可以直接判定维度 X 是均匀随机字段,不需要再做偏好分析。
这个模板可以直接套到后续任何新维度上。比如 B10 要做品类共现,如果某个新维度(比如页面 id)满足这三条,那这个维度同样不可分。
10.2 熵作为通用工具的迁移方式
熵这个工具不只能用在城市维度上。任何"分布集中程度"的问题都可以用它。
迁移的时候注意三件事。第一,确定归一化基准。B09 是 ln(20),因为品类数是 20。换场景时,归一化基准要换成 ln(实际类别数)。第二,处理零份额。占比为零的类别在 ln(0) 上没有定义,用 CASE 语句跳过。第三,明确判读标准。B09 用的是 0.99 阈值,这个阈值可以根据场景调整;实际迁移时,通用的 SQL 模板是这样的:
sql
SELECT
group_key,
SUM(CASE WHEN share > 0 THEN -share * LN(share) ELSE 0 END) AS entropy,
SUM(CASE WHEN share > 0 THEN -share * LN(share) ELSE 0 END) / LN(N) AS entropy_norm
FROM your_dist_table
GROUP BY group_key;
把 group_key 换成实际的分析维度,N 换成实际类别数,就能直接用。
11. 对后续实验的影响
-
B10 品类共现与关联规则。 城市维度不可用,但品类共现本身跟城市无关,可以正常做。B09 已证明 city_id 是随机字段,所以 B10 不需要再按城市切片,直接在全量数据上算共现。不过 B08 发现搜索词只有六个且完全均匀,B04 发现品类均匀,这两个前置结论会让 B10 的共现矩阵也可能呈现均匀分布。B10 探测阶段要重点验证这一点,如果共现矩阵也均匀,那关联规则挖掘出来的强规则就要谨慎解读。
-
B11 用户 RFM 与聚类分群。 一百个用户的样本本来就小,规则分层之后每层的用户数会更少。加上 K-means 聚类后,聚类稳定性需要专门评估。B09 的教训是:在动手分层之前先探测,确认用户行为本身是否有区分度。如果 F 和 M 也呈现均匀分布,那 RFM 分层的统计意义会很有限,需要提前在设计阶段给出应对方案。
-
B12 异常流量与刷单识别。 城市维度不能作为异常维度,但 B09 的数据给 B12 提供了两个正常基线。第一个基线是城市维度上的分布完全均匀,任何城市维度的异常都值得怀疑。第二个基线是手机搜索的全局异常,可以作为已知异常样本进行对照。B12 的三种方法(卡方检验、Z 分数、孤立森林)都要在这两个基线上做校准,避免误报。
-
B13 用户行为路径挖掘。 B07 已经发现 n-gram 条件概率均匀,路径无预测力。B13 用马尔可夫链和路径聚类重新挖,目标是找到 B07 没发现的模式。城市维度不可用,所以 B13 的 session 路径分析不引入城市切片。
-
B14 用户画像与标签体系。 城市维度不能作为用户画像的特征维度。B09 已经证明 city_id 与用户无绑定,把它作为特征反而会引入噪声。B14 的特征工程只从行为维度提取,包括行为频率、活跃时段、session 长度、转化率、搜索偏好、品类分布。
-
B15 品类推荐系统。 城市维度不能作为推荐的辅助特征。用户-品类交互矩阵不包含城市维度。B09 的结论进一步说明这份数据的用户画像本身很薄,推荐的区分度可能偏低。
12. 工程踩坑
踩到两个新坑,都不算严重,但值得记录。
第一个坑是数组字段的处理。order_category_ids 和 pay_category_ids 在 DWD 表里是 array 类型,不能直接 GROUP BY。需要用 LATERAL VIEW explode 把它们展开成多行。展开之后每一行只保留数组里的一个元素,其他字段保持不变。这是 Hive 处理数组字段的标准做法。
第二个坑是熵计算里的 ln(0)。当某个城市某个品类的占比为零时,零乘以零的自然对数在数学上没有定义,Hive 会返回 NULL,进而污染整个城市的熵值。解决办法是用 CASE 语句把占比为零的情况单独处理,贡献记为零。
此外 B09 沿用了一些 B01 到 B08 已经总结过的通用约束。比如每个 HQL 脚本开头都要带一组 SET 参数,把 MapReduce 任务的内存控制在集群承受范围内。比如 Hive 的 WITH 子句必须写在 INSERT 之前,跟标准 SQL 相反。比如排序要用 seq_in_session 字段而不是时间戳,避免同秒钟内的乱序。这些约束在 B01 到 B08 的报告里都有记录,B09 直接继承。
13. 写在最后
- 还没搞明白,需要进一步探索的:
- city_id 的随机分配机制是什么? 是均匀随机还是加权随机?B09 观察到 26 个城市的 user_cnt 都是 100,看起来是均匀随机,但没有进一步验证。
- 品类分布在所有维度上都均匀吗? B03 发现品类均匀,B04 发现品类均匀,B08 发现搜索词均匀,B09 发现城市维度也均匀。这个模式是否是合成数据的通用特征?B10 品类共现分析可以继续验证。
- 手机的全局异常到底是怎么产生的? B08 发现手机搜索量只有其他词四成,B09 确认这是全局现象。是数据构造时人为压低,还是真实业务现象?B12 异常检测时可以继续追。
- 小结:
城市偏好分析,看起来是一个标准的业务分析任务。但探测一跑出来,user_cnt 恒等于 100 这个数字就直接把整个设想推翻了。这说明探测的重要性不亚于分析本身。如果跳过探测直接建表,最终产出的可能是一堆看似有意义的"城市偏好排行榜",而这些排行榜在数据上毫无意义。
B09 交付的不是一份城市偏好分析,而是一份"证明这个维度不可分析"的分析。这份分析的价值不在于发现了什么,而在于明确地告诉后续实验:城市维度这条路走不通,别再花时间了。
- 踩过的坑速查表:
| 坑 | 表现 | 解法 |
|---|---|---|
order_category_ids 是数组 |
直接 GROUP BY 报错 | 用 LATERAL VIEW explode |
ln(0) 未定义 |
熵计算返回 NULL | 用 CASE 语句把 0 贡献记 0 |
Hive WITH 必须在 INSERT 前 |
INSERT ... WITH 报 ParseException |
改为 WITH ... INSERT |
tee 前必须 cd |
日志落盘失败 | 每次先 cd /home/hadoop/b09 |
| 窗口函数 SUM(SUM(x)) OVER | 写错会得到每行而不是分组总和 | 先内层 SUM 分组,再外层 SUM 求和 |
14. 附录:完整脚本
本文涉及的全部脚本,都在 /home/hadoop/b09/sql/ 下:
| 文件 | 用途 |
|---|---|
probe1.hql |
探测城市维度分布(4.1) |
probe2.hql |
探测城市 × 品类(4.2) |
probe3.hql |
探测城市 × 搜索词(4.3) |
01_build_city_category_dist.hql |
建 ads_city_category_dist(7.1) |
02_build_city_summary.hql |
建 ads_city_summary(7.2) |
03_verify_all.hql |
15 项验证(8) |
其余探测命令是 hive -e 一行式,已在正文中给出,直接复制运行即可。
表结构速查:
ads_city_category_dist(520 行 = 26 城市 × 20 品类)
| 字段 | 说明 |
|---|---|
| city_id | 城市 ID |
| cate_id | 品类 ID |
| click_cnt | 点击次数 |
| order_cnt | 下单次数 |
| pay_cnt | 支付次数 |
| click_share | 该品类在该城市点击中的占比 |
| order_share | 该品类在该城市下单中的占比 |
| pay_share | 该品类在该城市支付中的占比 |
ads_city_summary(26 行,每行一个城市)
| 字段 | 说明 |
|---|---|
| city_id | 城市 ID |
| total_click | 该城市总点击 |
| total_order | 该城市总下单 |
| total_pay | 该城市总支付 |
| click_entropy | 点击分布的熵 |
| click_entropy_norm | 归一化点击熵 |
| order_entropy_norm | 归一化下单熵 |
| pay_entropy_norm | 归一化支付熵 |
| top_click_cate | 头号点击品类 |
| max_click_share | 最大品类份额 |