微博上有 4089 万条带疫情关键词的公开数据,时间从 2019 年 12 月到 2020 年 4 月,共 152 天。我把它们做成了一套能跑、能点、能回放的分析系统:离线链路算情感、建转发图、看空间分布,实时链路按分钟回放这段时间,大屏上能看到某一分钟里负面占比突然抬头。整套东西跑在一台 16 GB 内存、机械硬盘的 Windows 笔记本上,没有集群,没有对象存储,没有云。项目由我与 Agent 结对完成:数据怎么治理、指标口径怎么定、算法选哪个、哪里该停下来,由我拍板;脚本、Spark 作业、后端和前端由 Agent 落地,产出逐条回到清单验收。这篇不写需求文档,只写那些「如果不做就会得到错误结论」的决定。
数据:五张表,三个必须先验证的假设
先看手里有什么。
| 数据集 | 规模 | 落盘体积 |
|---|---|---|
| 推文原文 | 40,893,832 行 | 13.2 GB |
| 带坐标推文 | 45,901,994 行 | 6.66 GB |
| 用户画像 | 20,000,000 行 | 501.5 MB |
| 转发明细 | 3,651,382 行 | 119.1 MB |
| 用户级转发边 | 3,260,465 行 | 103.6 MB |
| 话题统计 | 564,870 个 | 15.7 MB |
正式开工前我花了半天做三件事验证,每一件都改变了后面的做法。
第一,两张表能不能按 id 关联。把带坐标推文和推文原文按 _id 内连接,命中率只有 2.9%。这不是数据脏,是两份数据本来就来自不同的采集批次,同一时间段的两套快照。结论很直接:空间分析只能独立跑,不能拿推文表的情感字段往地图上贴。
第二,发帖的人有没有画像。967 万发帖用户,100% 能在 2000 万条的画像表里找到。这个结果让用户维度的聚合变得很安全,也说明两份数据虽然批次不同,用户体系是同一套。
第三,缺失值是不是真的空。不是。省份字段里「其他」占 13.8%,排第一;生日有效率 51.1%,其中 8.4 万条填的是 1900-01-01。这两个数字都很危险:把「其他」当空值丢掉,等于删掉了最大的一类省份;把 1900 年当出生年算,平均年龄会凭空多出十几岁——实测差 16.73 岁,所以年龄这个维度我直接放弃了。
三件事加起来不到半天,但后面每一个结论都建立在它们上面。
入库:37 GB 解不开,只能边解压边解析
原始数据是两个 tar.gz,解压后 37 GB。笔记本上同时放下压缩包、中间 CSV 和 Parquet 是不可能的,所以入库脚本不做「先解压再读」,直接流式走完全程:gz 流 → 跳过 512 字节 tar 头 → 按头部记录的长度截断包尾 → pyarrow 的 CSV 流 → 每 400 万行落一个 Parquet 分片。
header = fh.read(512)
total = tar_size(header) # 超过 8 GB 的 tar,size 字段改用 base-256 编码
payload = TarPayload(fh, length=total) # 只读到 total,包尾补零不进解析器
stream = pa.PythonFile(payload, mode="r")
reader = pacsv.open_csv(
stream,
read_options=pacsv.ReadOptions(block_size=1 << 26),
parse_options=pacsv.ParseOptions(delimiter=","),
)
两个坑都在 tar 这一层。第一个是文件长度:标准 tar 头里存长度的字段只有 11 个字节,用八进制写。超过 8 GB 的文件装不下,规范要求把最高位打开、改用 base-256 编码。所以 raw[0] & 0x80 为真时,得按大端整数解析余下的字节,否则 13 GB 这个包的长度会被读成一个天文数字,后面全部错位。
第二个是包尾填充:tar 流末尾一定有 1024 字节起步的全零块,用来对齐。CSV 解析器不认识这个约定,会把零字节当成数据行继续读,报出一堆列数不匹配。解决办法是用头部记录的长度截断——我知道文件应该有多少字节,读到那里就停。
| 数据集 | 行数 | 分片 | 用时 |
|---|---|---|---|
| 推文原文 | 40,893,832 | 11 | 1.2 分钟 |
| 带坐标推文 | 45,901,994 | 12 | 0.7 分钟 |
还有一个隐形的坑:pyarrow 写 Parquet 默认用 timestamp[ns],而 Spark 只认微秒。报错是 Expected timestamp, actual Parquet INT64,看着像类型没对上,其实是精度不对。显式写成 timestamp[us] 就好了。
另外两个坑不在代码里,在环境里。Spark 的安装路径里有中文,启动时直接抛 ClassNotFoundException: org.apache.spark.deploy.SparkSubmit——276 个 jar 一个不少,真原因是 spark-submit.cmd 在非 ASCII 路径下的截断。还有最初用逗号硬切 CSV,正文里带引号和换行的行被切碎,行数对不上;换成标准解析后与官方记录数完全一致。
顺带一个反直觉的结论:读数据从来不是瓶颈。解析速度稳定在每秒四十多万行,十几 GB 一分多钟就进库了。真正吃时间的是下面两节。
情感分析:三代方案,5.7 小时压到 7 分钟
4089 万条文本要打极性标签。我试了三代方案,第一代直接不能用。
| 方案 | 单核速度 | 全量耗时 | 卡在哪 |
|---|---|---|---|
| SnowNLP | 48 条/秒 | 11.7 小时(20 核并行) | 规则很轻,但中文长短句表现不稳 |
| cnsenti | 33 条/秒(纯匹配基准 989) | 5.7 小时 | jieba 每条都要建词图 + 维特比解码 |
| 词典 + Aho-Corasick | 约 9,000 条/秒 | 7.3 分钟 | 否定和程度得自己处理 |
关键判断是:慢的不是查词典,是分词。微博这种短文本,一句里混着 @、话题标签、URL、表情符号,分词器每条都要从头建一遍词图。所以第三代方案换的是匹配方式,不是词典——复用 cnsenti 自带的知网情感词表,把正面词和负面词塞进一个 Aho-Corasick 自动机,一次扫描拿到所有命中,再对每个命中做两步局部判断。
for end, (kind, word) in automat.run(text):
start = end - len(word) + 1
ctx = text[max(0, start - 3):start] # 命中的前 3 个字
if _nearest(ctx, NEGATIONS):
kind = "n" if kind == "p" else "p" # 有否定词就翻转极性
weight = _nearest(ctx, DEGREE) or 1.0 # 程度副词:0.5 / 1.2 / 1.5 / 2.0
def _nearest(ctx, words):
for size in (1, 2, 3): # 先看紧挨着的 1 个字,再看 2 个、3 个
if ctx[-size:] in words:
return ctx[-size:]
return None
拿 1500 条样本和 cnsenti 对拍:极性一致 79.3%,三分类一致 78.7%。够用了——两种方案都读不懂反讽,差距主要来自分词边界。
| 极性 | 微博数 | 平均转发 | 转发过百 | 转发过千 |
|---|---|---|---|---|
| 正面 | 31,044,568 | 4.80 | 50,177 | 9,496 |
| 中性 | 2,225,223 | 3.75 | 3,026 | 414 |
| 负面 | 7,624,041 | 1.62 | 10,161 | 1,187 |
这里我犯过一个错。第一版结论写成「正面微博被转发的概率是负面的 8 倍」:过千的正面 9,496 条,负面 1,187 条,正好 8:1。这个数是错的,因为它比的是绝对量,而正面微博本身就比负面多 4 倍。按各自基数折算,每 10,000 条里正面 3.06 条过千、负面 1.56 条,差距大约 2 倍。再看分布,九成五以上的微博转发数为 0(正面 95.45%,负面 95.09%)。所以正确的说法是:正面内容的转发尾部更厚,但绝大多数微博根本没人转。
还有一个反例我留着没删。2020-04-04 全国哀悼日,正面占比是全期第二高(0.8491),仅次于 3 月 8 日(0.8758)。原因不是那天大家情绪特别好,而是「逝世」「牺牲」「烈士」这些词被词典判成了负面,而哀悼日的文本里它们密度极高。词典法读不懂语境,这类日子的极性分数要单独说明,不能直接当情绪指标用。
转发图:234 万节点,画出来是一堆星星
转发明细 365 万条,每条只带原微博的 _id,不带「转给谁」。要连边必须回原始表做一次自连接:拿原微博 id 反查作者 id。
origins = reposts_doc.select("_id", "user_id", "reposts_count") \
.withColumnRenamed("user_id", "target_user")
edges = reposts.select("user_id", "origin_weibo") \
.join(origins, reposts.origin_weibo == origins._id, "inner")
自连接之后 365 万条明细里剩下一部分能连成边——原微博不在数据集里的自然丢弃。把同一对用户的多条转发合并,得到 326 万条有向边、233.7 万个节点;自环 43,495 条(1.33%)先剔掉。
| 指标 | 数值 | 含义 |
|---|---|---|
| 入度为 0 的节点 | 2,199,904(94.2%) | 只转发,不被转发 |
| 最大入度 | 85,627 | 全网被转最多的账号 |
| 弱连通分量 | 41,380 个,最大 223.5 万(95.7%) | 几乎整张图是一个大连通块 |
| 强连通分量 | 最大只有 82 | 几乎没有双向互动 |
这张图不是社交网络,是无数条微博各自的星形拼贴。94% 的节点只出现在「某颗星星的一条射线」的位置上;强连通分量最大才 82,说明微博的转发生态里几乎没有「你转我、我转你」的双向关系。所以在这张图上谈「社区」,指的其实是「同一批人转了同一件事」。
| 算法 | 耗时 | 社区数 | 模块度 Q |
|---|---|---|---|
| Louvain | 21.4 秒 | 41,714 | 0.7857 |
| Leiden | 7.4 秒 | 41,837 | 0.7985 |
选了 Leiden:快 3 倍,Q 还高 0.013。两个算法都要在无向投影上跑——有向图跑不了。
PageRank 那一段有个反例值得记:排名第一的账号粉丝 137 万,在全体用户里粉丝排第 8,038;排第 4 的账号粉丝 1.07 亿,粉丝排名第 8。被转发多不等于粉丝多。PageRank 在这张图上量的是「处在多少个级联的根上」,不是影响力。这个区别给运营看的时候必须讲清楚,否则「影响力榜」就退化成了粉丝榜。
级联是另一条线,把同一条微博的所有转发聚成一个事件:
| 指标 | 数值 |
|---|---|
| 级联数 | 293,631 |
| 平均规模 | 11.81 次转发 |
| 最大规模 | 55,877 |
| 规模为 1 | 178,088(60.7%) |
| 转发过千 | 392(0.13%) |
尾部里有两条值得并排看:一条 18,114 次转发,原作者只有 239 个粉丝;另一条 15,477 次转发,原作者 1.07 亿粉丝。粉丝差五个数量级,转发量在同一个量级。传播规模由内容所处的位置决定,粉丝数只是入场券。
再看扩散节奏。最大那条级联(2020-01-28 发布)的转发峰值出现在 2 月 6 日 15 点,逐小时是 13 时 311、14 时 1,663、15 时 2,438、16 时 1,994、17 时 1,491。它在几小时内二次爆发,之后是长尾。这对「什么时候该盯盘」有直接指导:事件发生后的头几个小时,比之后几天都值钱。
两个补充实验。一是独立级联模拟:在真实转发图上按 p=0.05 传 8 轮,全网 PageRank 第一的账号只感染了 4294 人,占全网 0.18%,而且第一轮就到顶;粉丝 202 万但只被转 389 次的账号,最终只带动 15 人。二是同质性重算:第一版拿合并比例当基准,算出「性别反向同质」;正确做法是拿两侧各自分布的交叉乘积当基准,重算后性别 0.97(没有效应)、省份 2.11(明显聚集)。同一份数据,基准一换,结论就翻面。
话题:站内热度和热搜榜,量的是两件事
4089 万条微博里共出现 4980 万次话题提及,去重后 56.4 万个话题。Top3 是「战疫打卡行动」202 万次、「武汉加油」112 万次、「哀悼牺牲烈士和逝世同胞」110 万次。
把站内话题量和热搜榜单(26,022 个)做交叉,结果很干净:站内话题只有 3.37% 上过热搜,两者的相关系数是 0.073(峰值热度)、0.087(上榜次数)、-0.039(最高排名)——基本无关。
反例更说明问题。「最新疫情地图」站内只有 32 万次提及,峰值热度却有 1705 万、排到过第 1;「战疫打卡行动」站内 202 万次,峰值热度 208 万,最高第 6。站内量大说明有人在发,热搜高说明有人在搜,这两个行为之间没有换算关系,硬拼成一张榜就是两个指标互相污染。
还有一个不能忽略的东西:模板刷屏。「上虎扑关注新型冠状病毒肺炎」这一句同形内容出现了 30 万条。它会把任何按关键词或话题做的统计抬高一个量级,做 Top 榜之前必须先按文本去重。
空间:先归一化,再谈「哪里最热」
带坐标的那张表和推文表不是同一批数据(前面 2.9% 已经说明),所以空间分析独立跑。4590 万行坐标全部解析成功,境内 97.22%,按 0.25° 网格聚合后剩 26,851 个格子。
| 网格中心 | 发帖量 | 发帖人数 |
|---|---|---|
| 上海(121.50E, 31.25N) | 694,349 | 200,967 |
| 北京 | 609,752 | — |
| 南京 | 581,987 | — |
| 杭州 | 537,651 | — |
| 西安 | 528,014 | — |
| 苏州 | 507,477 | — |
| 深圳 | 411,614 | — |
按画像里的省份字段排,是广东 4,858,497、北京 3,692,513、山东 3,116,762。但发帖量和传播强度不是一回事:北京平均每条被转 1.37 次、上海 1.28、湖北 0.99,广东只有 0.25、山东 0.15、河北 0.13。广东发了最多的微博,每条炸开的距离最短。
这一节最重要的一件事是分母。全量逐日发帖量从 2019-12-01 的 191,363 涨到 2020-04-19 的 589,588,三倍。这不是疫情变严重了,是数据集的采集量在变。如果直接画绝对量,湖北最高的五天全在 4 月,结论会是「解封之后湖北讨论度最高」,完全反了。
改画「占当日全国的比例」之后,故事才对:湖北在封城当天 1-23 是 3.91%,峰值 2-15 是 4.28%,然后回落到 2.66%;北京在 2-06(李文亮去世次日)冲到 9.14%,是所有省份里最高的单日值;广东从 8.37% 涨到 11.26%;海外从 4.92% 降到 3.48%,峰值在 2019-12-26 的圣诞假期。
分母也在变的时候,绝对量曲线讲的是采集器的故事,不是人的故事。
模拟实时:把 152 天压成一条能读的时间轴
回放不是为了假装实时,是为了让人看懂一条曲线怎么长出来。历史页只能给一天的汇总,看不到「某一分钟里负面占比突然抬头」。
一个前提决定了所有设计:原始事件本身就落在整分钟上(时间戳是老实的 :00)。所以回放按分钟推进,不再往下切秒。旧版是「5 秒播一天」,等于 17280 倍速,一分钟的事件被撒进 0.003 秒里,前端根本画不出来——那不是实时,是加速翻页。现在是 1 倍速每秒推进 1 个模拟分钟,可选 1、2、5、10 倍,前端固定 60 分钟的稠密窗口。
data/stream_events(152 个天分区)
│ 回放器:虚拟时钟 → searchsorted 取片
▼
stream/spool/evt_000123.parquet ← 先写 tmp/ 再 os.replace
│ Spark Structured Streaming(file source,1 秒微批,每批最多 8 个文件)
▼
stream/hot/*.parquet(分钟聚合、话题 Top20、地区、告警)
│ GET /api/snapshot(每 0.5 秒一次)
▼
dashboard/web(监测大屏)
sim_clock += timedelta(minutes=speed * dt) # dt 是这一轮真实耗时,封顶 1 秒
target = np.datetime64(sim_clock.to_pydatetime(), "us")
end = int(np.searchsorted(times, target, side="right"))
if end > cursor:
write_piece(frame.iloc[cursor:end], seq) # 一个模拟分钟一个文件
虚拟时钟只跟真实耗时挂钩,不跟 CPU 快慢挂钩。写文件先落 tmp 再 os.replace,是为了让 Spark 永远看不到写了一半的 Parquet。这四条是踩出来的:
- 限流。旧版拿 spool 目录里的文件个数当积压指标。文件从来没被删过,跑到八千个分片之后它永远判定「还在积压」,一停就是永久停摆。现在改成比较两个时钟:回放器的虚拟时钟,和流任务在
live_meta.json里回报的已处理时间。落后超过 3 分钟就停钟等,追上了再走。 - 清理。spool 分片要等流任务确认读完之后 10 分钟才删。手动删过一次,Spark 直接报「文件不存在」把整个 query 弄挂,只能重启。
- 时区。回放器写出去的时间已经换成了北京墙钟时间。如果 Spark 会话不显式设成 UTC,它会按本机时区再加 8 小时,大屏上的时间就整体漂了。
- 启动脚本。用 PowerShell 5.1 跑含中文的 .ps1(UTF-8 无 BOM)会解析失败,读进来全是乱码;脚本内容改成纯 ASCII 就没这问题了。
- 重置。控制面里有一个
run_id。点「重置」,run_id 加一,流任务发现变化就停 query、清 checkpoint、清热层,再把新 run_id 写进ack.json;回放器要等到 ack 才继续。不做这个握手,重放会接着上一轮的 offset 跑。
告警三条,都在流任务的微批里算:
| 规则 | 触发条件 | 冷却 |
|---|---|---|
| 负面占比异常 | 60 分钟窗口内负面占比超过 30%,且样本不少于 60 条 | 同对象 120 分钟 |
| 地区突增 | 某省 60 分钟发帖量超过基线 3 倍,且不少于 80 条 | 同上 |
| 话题突增 | 某话题同理,阈值 60 条 | 同上 |
基线取前三个 60 分钟窗口的平均值。开局前 180 分钟不判突增——基线不够时,任何一次正常的早晚高峰都会被判成异常。
实测连跑 110 秒,34 个微批,模拟时间从 1 月 20 日走到 1 月 29 日 18:59,累计 572.9 万条事件,单批耗时 0.5 到 0.7 秒,错误 0。机械硬盘上这个数字可以接受——瓶颈在磁盘随机读,不在 Spark。
前端:Streamlit 换成自研 SPA
第一版是 Streamlit 写的九个页面,能跑,但不好用。每个控件都要重跑整个脚本,图表重新画一遍,切一次页面两三秒;而且 Streamlit 进程常驻内存,和 Spark 抢那 16 GB。
现在的结构是:一个标准库的 ThreadingHTTPServer 监听 127.0.0.1:8601,一个 hash 路由的单页应用,九个历史页加一个实时大屏。没有前端框架,原生 ESM 按需 import(),Plotly 放在 vendor 目录而不是 CDN——断网也能用。
| 路由 | 内容 |
|---|---|
| /api/overview | 数据集清单、逐日量、KPI |
| /api/geo | 网格聚合、省份占比、传播强度 |
| /api/topics | 话题 Top20、单话题逐日、热搜对照 |
| /api/cascade | 级联分布、单条级联的逐日增长 |
| /api/influence | PageRank 榜、社区划分、入度分布 |
| /api/profile | 用户画像与个体关系 |
| /api/simulate | 独立级联模拟与敏感性 |
| /api/snapshot | 实时大屏的热层快照 |
后端只读。历史页的数据全是离线算好落盘的 Parquet,进程内用 lru_cache 缓存,首次查询也在毫秒量级。真正需要写的只有两个文件:control.json(控制面)和 ack.json(重置握手)。一个只读服务,重启是安全的。
实时大屏和历史页读的是同一份事件表,所以同一个话题在大屏上看到的量和在历史页看到的量不会对不上。这是把「模拟」和「分析」放进同一个进程的唯一理由。
复盘:五条判断
- 口径先于算法。2.9% 的关联率、13.8% 的「其他」、三倍的采集量,三个数都没涉及算法,但它们决定了结论对不对。
- 瓶颈不在 IO。每秒四十多万行的解析,十几 GB 一分多钟进库;真正吃时间的是 4089 万条文本的情感标注,和转发图上的图算法。
- 缺失值是信息,不是噪音。「其他」排第一说明省份是用户自填的,那本身是一种数据来源;1900 年占位说明用户会随手填。当成噪音删掉,判断就少了一层。
- 模拟实时是限流问题,不是吞吐问题。数据是回放的,集群不集群都不重要;难的是别把前端喂爆,别让 Spark 读到半个文件。
- 反例要留着。哀悼日的正面占比、8:1 的错觉、2.9% 的命中率——删掉它们,分析就变成了看图说话。
局限也写清楚:词典法读不懂反讽和长距离否定;数据集是按疫情关键词筛过的,非疫情内容天然缺失,占比类结论只在「疫情叙事内部」成立;单机机械硬盘只能给 Spark 2 GB driver 和 4 个 shuffle 分区,规模再大一倍这套配置就跑不动了。
数据来自公开的 Weibo-COV 微博疫情数据集,分析代码与结果都留在本机。