ZHANG.LOG
PySparkPython大数据数据可视化

四千万条微博:情感、转发图与实时回放

发布于 2026-10-27阅读时间 10 分钟

微博上有 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 分片。

python
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,832111.2 分钟
带坐标推文45,901,994120.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 万条文本要打极性标签。我试了三代方案,第一代直接不能用。

方案单核速度全量耗时卡在哪
SnowNLP48 条/秒11.7 小时(20 核并行)规则很轻,但中文长短句表现不稳
cnsenti33 条/秒(纯匹配基准 989)5.7 小时jieba 每条都要建词图 + 维特比解码
词典 + Aho-Corasick约 9,000 条/秒7.3 分钟否定和程度得自己处理

关键判断是:慢的不是查词典,是分词。微博这种短文本,一句里混着 @、话题标签、URL、表情符号,分词器每条都要从头建一遍词图。所以第三代方案换的是匹配方式,不是词典——复用 cnsenti 自带的知网情感词表,把正面词和负面词塞进一个 Aho-Corasick 自动机,一次扫描拿到所有命中,再对每个命中做两步局部判断。

python
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,5684.8050,1779,496
中性2,225,2233.753,026414
负面7,624,0411.6210,1611,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。

python
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
Louvain21.4 秒41,7140.7857
Leiden7.4 秒41,8370.7985

选了 Leiden:快 3 倍,Q 还高 0.013。两个算法都要在无向投影上跑——有向图跑不了。

PageRank 那一段有个反例值得记:排名第一的账号粉丝 137 万,在全体用户里粉丝排第 8,038;排第 4 的账号粉丝 1.07 亿,粉丝排名第 8。被转发多不等于粉丝多。PageRank 在这张图上量的是「处在多少个级联的根上」,不是影响力。这个区别给运营看的时候必须讲清楚,否则「影响力榜」就退化成了粉丝榜。

级联是另一条线,把同一条微博的所有转发聚成一个事件:

指标数值
级联数293,631
平均规模11.81 次转发
最大规模55,877
规模为 1178,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,349200,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 分钟的稠密窗口。

code
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(监测大屏)
python
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/influencePageRank 榜、社区划分、入度分布
/api/profile用户画像与个体关系
/api/simulate独立级联模拟与敏感性
/api/snapshot实时大屏的热层快照

后端只读。历史页的数据全是离线算好落盘的 Parquet,进程内用 lru_cache 缓存,首次查询也在毫秒量级。真正需要写的只有两个文件:control.json(控制面)和 ack.json(重置握手)。一个只读服务,重启是安全的。

实时大屏和历史页读的是同一份事件表,所以同一个话题在大屏上看到的量和在历史页看到的量不会对不上。这是把「模拟」和「分析」放进同一个进程的唯一理由。

复盘:五条判断

  1. 口径先于算法。2.9% 的关联率、13.8% 的「其他」、三倍的采集量,三个数都没涉及算法,但它们决定了结论对不对。
  2. 瓶颈不在 IO。每秒四十多万行的解析,十几 GB 一分多钟进库;真正吃时间的是 4089 万条文本的情感标注,和转发图上的图算法。
  3. 缺失值是信息,不是噪音。「其他」排第一说明省份是用户自填的,那本身是一种数据来源;1900 年占位说明用户会随手填。当成噪音删掉,判断就少了一层。
  4. 模拟实时是限流问题,不是吞吐问题。数据是回放的,集群不集群都不重要;难的是别把前端喂爆,别让 Spark 读到半个文件。
  5. 反例要留着。哀悼日的正面占比、8:1 的错觉、2.9% 的命中率——删掉它们,分析就变成了看图说话。

局限也写清楚:词典法读不懂反讽和长距离否定;数据集是按疫情关键词筛过的,非疫情内容天然缺失,占比类结论只在「疫情叙事内部」成立;单机机械硬盘只能给 Spark 2 GB driver 和 4 个 shuffle 分区,规模再大一倍这套配置就跑不动了。

数据来自公开的 Weibo-COV 微博疫情数据集,分析代码与结果都留在本机。