简介:基于Hadoop的朴素贝叶斯文本分类器项目,面向大数据与机器学习方向的在校生、毕业设计及课程设计人群,适合作为毕设或课设选题参考。项目采用MapReduce完整实现贝叶斯分类器的训练与测试流程,可输出训练模型并对测试文档进行分类,同时计算精确率、召回率和F1值,是理解分布式分类算法落地的典型范例,对分类效果评估也有直观展示。资源包共552个文件,压缩后约3.75MB,包含518个txt语料文本、9个Java源代码、Markdown与Word文档说明、PDF报告以及工程配置文件等,结构清晰,便于直接导入学习与二次开发。目前已有230人学习/下载,代码均经过测试运行成功,据作者介绍答辩评审均分达96分。除源码与数据集外,还附带详细的实验说明和运行指导,有助于快速复现实验并掌握Hadoop MapReduce编程思路。
1. 从课程设计到离线批处理:Hadoop 上跑朴素贝叶斯,到底在解决什么问题
搜“基于Hadoop开发实现的朴素贝叶斯文本分类器”的人,一大半是课程设计或毕业设计,另一大半是想在公司离线数仓里做批量文本分类。先别急着把它当成一个填空题——真正的分水岭在于:你拿到的源代码,是单机版把整个训练集装在内存里跑的那种朴素贝叶斯,还是能真正提交到 Hadoop 集群、按 MapReduce 方式流式统计的分布式实现。这两种代码的差距极大,前者训练集稍微放大一点就内存溢出,后者可以把训练语料扩展到 GB 甚至 TB 级。这篇笔记要把第二套方案的完整链路讲透:训练阶段拆成两次 MapReduce、分类阶段变成一次分布式求和,以及每一步的参数、命令和踩过的坑。适合正在做 Hadoop 课程设计的在校生、刚配好伪分布式环境想拿真实数据练手的初学者,以及需要在离线批处理里做文本分类但不想上 Spark 的工程师。
2. 朴素贝叶斯为什么适合放进 Hadoop:训练等于两次 MapReduce,分类等于一次求和
2.1 从贝叶斯公式到文本分类:条件独立假设才是分布式切分的依据
朴素贝叶斯做文本分类,本质上是在算“给定一段文本,它属于某个类别的概率”,然后取概率最大的那个类别。公式拆开看就是 P(C|D) = P(C) × P(D|C) / P(D),其中 D 是一篇文档,C 是类别。因为 P(D) 对所有类别都一样,实际比较时可以直接扔掉,只要算 P(C) 和 P(D|C) 的乘积。
麻烦出在 P(D|C) 上。D 是一整句话,直接算“这句话在类别 C 下出现的概率”几乎不可行,语料里根本不会有足够多的样本覆盖同一句话。所以才需要朴素贝叶斯最核心的假设:文档里每个词的出现是条件独立的。有了这个假设,P(D|C) = P(w1|C) × P(w2|C) × … × P(wn|C),一句话的概率就拆成了一堆词的概率乘积。
这里能看到两个对分布式友好的特性:第一,词与词之间不再有顺序依赖,统计时可以完全并行地按“单词 类别”计数,这正是 MapReduce 最擅长的事情;第二,模型文件只需要保存每个类别里每个词的条件概率,外加每个类别的先验概率,模型非常小。所以整条训练链路可以拆成 Job1 统计词频、Job2 归一化算概率,分类时再对每个类别做一次求和,不需要任何迭代计算,MR 天然合适。
2.2 训练集的分布式切分:把“单词 × 类别”计数当成统计单元
在 Hadoop 上写朴素贝叶斯,第一个要转变的思路是:不要想着把整篇文档作为一个统计单元,而要把“词 × 类别”的组合作为最小的统计单元。
假设训练集是 HDFS 上的一个目录,每行是一条样本,格式约定为“类别\t正文”。Map 阶段的任务是读入每一行,对正文分词,然后输出形如“word#category → 1”的键值对。Reducer 阶段把相同“word#category”的计数累加。这个过程和 WordCount 几乎一模一样,只是 Key 从单词换成了“单词 # 类别”。这样一来,无论训练集怎么在 DataNode 上做分块,每个 MapTask 只需处理自己那一块数据,输出经过 shuffle 归并,全局统计就出来了。
这样设计的另一个好处是天然抗数据倾斜。比如“的”“了”这种高频词,在多个类别里都会出现,计数器按“word#category”拆开,每个 Reducer 处理的数据量相对均衡。如果只拿单词做 Key,某一个高频词会把大量数据引到同一个 Reducer,集群再大也白搭。当然,真实的文本分类不能只看词频,但要清楚这个方案的可扩展性来自统计单元的拆分,这一点搞明白,后面写代码就不会跑偏。
2.3 为什么不用 Hive SQL 或直接上 Spark:三种方案的边界对比
常见做法里有三条路:用 Hive SQL 配合 UDF 做词频统计、用 Spark MLlib 的 NaiveBayes 直接训练、以及手写 MapReduce。很多人在 Hadoop 课程设计里选 MR,是因为题目要求“基于 Hadoop 开发”,但这个选择本身也有合理性。下面这张表是我实际比较过的结论:
| 对比维度 | 手写 MapReduce | Hive 写 SQL | Spark MLlib |
|---|---|---|---|
| 训练数据规模 | GB 到 TB 级,靠集群堆 | 同样能到 TB 级 | 能到 TB 级,且更快 |
| 代码量 | 100 行左右 | 50 行 SQL + UDF | 20 行代码 |
| 中间结果落盘 | 两个 Job 落两轮 HDFS | 中间表落盘多次 | 尽量走内存 |
| 环境依赖 | 只要 Hadoop | 要 Hive 或 Spark 驱动 Hive | 要 Spark 集群 |
| 适合场景 | 课程设计、轻量离线任务 | 已有数仓、不想写 Java | 迭代频繁、特征工程复杂 |
如果公司里 Hive 数仓已经很成熟,训练数据本身就在 Hive 表里,那直接用 Hive 做词频统计反而更划算;如果推理阶段要频繁调参、做特征工程,Spark MLlib 是更好的归宿。手写 MR 的价值在于:把朴素贝叶斯的统计逻辑拆到了最底层,任何依赖关系一眼可见,也不需要为它单独维护一套 Spark 集群。我一个朋友的团队曾经在四台机器的小集群上用 MR 跑千万级短文本分类,训练加预测整个流程稳定跑一个多小时,任务结束资源直接释放。先把这个方案吃透,后面再迁 Spark 也顺手。
3. 用 MapReduce 实现朴素贝叶斯训练:Job1 统计词频,Job2 归一化,附核心代码
3.1 Job1 的 Mapper:类别与正文如何拆分、分词结果怎么设计 Key
训练阶段的第一跳,是把原始语料变成“word#category → count”的计数结果。输入格式建议用 SequenceFile 或者纯文本都行,纯文本更直观。每行文本的格式约定为“类别\t正文内容”,如果语料里正文本身包含制表符,约定只按第一个 TAB 做切分,正文里剩余 TAB 一律保留。这句约定要写进文档说明里,否则后面换数据源极易出错。
下面是 Job1 的 Mapper 核心代码:
public class TrainMapper extends Mapper<LongWritable, Text, Text, LongWritable> { private Text outKey = new Text(); private LongWritable outVal = new LongWritable(1L); @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String line = value.toString(); int tabIndex = line.indexOf("\t"); if (tabIndex < 0) { return; // 没有类别分隔符的脏数据直接跳过 } String category = line.substring(0, tabIndex).trim(); String content = line.substring(tabIndex + 1); if (category.isEmpty() || content.isEmpty()) { return; } // 这里使用 IK 分词器,具体选型见第 4 章 Analyzer analyzer = new IKAnalyzer(true); TokenStream ts = analyzer.tokenStream("content", content); CharTermAttribute term = ts.addAttribute(CharTermAttribute.class); ts.reset(); while (ts.incrementToken()) { String word = term.toString(); if (word == null || word.trim().isEmpty()) { continue; } // Key 设计为 “word#category”,而不是单独 word 或单独 category outKey.set(word + "#" + category); context.write(outKey, outVal); } ts.end(); ts.close(); } }这段代码里最关键的就是outKey.set(word + "#" + category)。为什么要把单词和类别拼接在一起当 Key?因为朴素贝叶斯需要的是“单词在某个类别下的计数”,如果 Key 只放单词,Reducer 阶段还要遍历该单词在每个类别下的子计数,代码会变复杂,shuffle 的压力也会变大。拼接之后,同一个 Key 的所有 Value 天然落在同一个 Reducer 里,直接累加就是条件概率的分子。
整段逻辑在每行样本上的操作是:切出类别和正文,对正文分词,每个词输出一次“1”。为了保证 Key 里不含特殊字符,分词器输出的词里如果带有#或者空格,建议过滤掉,避免后续解析 Key 出错。IKAnalyzer 默认按中文词典切词,标点符号和英文单词会按空格分词,这里完全够用。
注意一个细节:outVal被复用了。很多初学者喜欢在循环里 new 一个 LongWritable,数据量大时对象创建会拖慢 GC。这里把outVal设成类字段,每次只set(1L),是 MR 编程里的常见优化。
3.2 Combiner 与 Reducer:为什么计数求和可以复用 Combiner
Mapper 的输出是海量的“1”,如果不做任何合并,shuffle 阶段就要把这些数值全部搬到 Reducer,集群里的网络和磁盘 IO 会被白白消耗。这时候需要加一个 Combiner,在 Map 端先做一次局部累加。
Combiner 和 Reducer 在代码上可以复用同一个类,前提是操作满足交换律和结合律。词频统计是纯加法,自然满足。但要注意:如果你的 Reducer 里做了字符串拼接、除法和读取上下文信息等操作,就绝对不能拿同一份代码当 Combiner,否则结果会直接出错。这里给出可以复用的 Reducer 类:
public class TrainCombiner extends Reducer<Text, LongWritable, Text, LongWritable> { private LongWritable outVal = new LongWritable(); @Override protected void reduce(Text key, Iterable<LongWritable> values, Context context) throws IOException, InterruptedException { long sum = 0L; for (LongWritable v : values) { sum += v.get(); } outVal.set(sum); context.write(key, outVal); } }在 Driver 里,jobs.setCombinerClass(TrainCombiner.class);和jobs.setReducerClass(TrainReducer.class);指向同一个类即可。Reducer 的逻辑和 Combiner 完全相同,只是输入规模更大。实际跑 100GB 训练集的时候,加了 Combiner 之后 shuffle 的数据量能少一个量级,这是最明显的性能收益。
Job1 的 Reducer 输出目录就是“word#category — count”的中间表。这些中间表本身也可以做压缩,建议在 Driver 里开启输出压缩,这样 Job2 的 Map 输入读 HDFS 时磁盘开销更小。压缩格式选 gzip 或 snappy 都行,Hadoop 原生支持,不需要额外引入依赖。
3.3 Job2 的归一化:把条件概率变成能查表的模型文件
有了词频统计结果,还缺两个东西:每个类别里所有单词的总词数,以及每个类别的样本数。这两个值通常有两种做法:一种是在 Job2 的 Reducer 里把所有 word 计数按类别汇总;另一种是在 Job1 里用 Hadoop Counter 直接统计类别样本数和总词数。我个人常用的做法是 Job1 里同时维护两个 Counter,Job2 负责单类别归一化,两边各取所需,不额外起 Job。
Job2 的 Mapper 读入中间表,解析出“word”“category”“count”,以 category 为 Key 输出,Value 里带着 word 和 count。Job2 的 Reducer 里会先遍历一遍把总词数算出来,再遍历第二遍输出每个词的条件概率。注意迭代器里的 Value 是复用的,第一遍遍历收集的数据必须做深拷贝,否则第二遍遍历时数据已经被覆盖。下面这段代码的重点就在防御性拷贝:
public class NormReducer extends Reducer<Text, Text, Text, Text> { private Text outKey = new Text(); private Text outVal = new Text(); @Override protected void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException { long totalCount = 0L; List<String> words = new ArrayList<>(); List<Long> counts = new ArrayList<>(); // 第一遍:统计该类别的总词数,同时深拷贝词和计数 for (Text val : values) { String[] parts = val.toString().split("\t"); if (parts.length != 2) { continue; } words.add(new String(parts[0])); // 深拷贝 counts.add(Long.parseLong(parts[1])); // 深拷贝 totalCount += Long.parseLong(parts[1]); } // 第二遍:输出 word#category → logP(w|c) // 直接输出原始概率或 log 概率都可以,log 概率更稳 for (int i = 0; i < words.size(); i++) { double prob = (counts.get(i) + 1.0) / (totalCount + words.size()); double logProb = Math.log(prob); outKey.set(words.get(i) + "#" + key.toString()); outVal.set(String.valueOf(logProb)); context.write(outKey, outVal); } } }这里有个关键点:分子和分母都做了拉普拉斯平滑,分子加 1,分母加该类别下不同词的数量。很多教程只写分词和计数,不写平滑,结果测试集里稍微出现一个没见过的词,整个乘积直接变零,分类就废了。
Job1 和 Job2 在 Driver 里串起来的方式是waitForCompletion,第一个 job 结束后再提交第二个。注意第二个 job 的输入路径就是第一个 job 的输出路径,路径要在代码里动态生成,不要写死,否则换数据集时容易误删中间结果。
4. 中文分词在分布式环境下的落地:词典加载、编码统一与文档头要写清楚的三个字段
4.1 分词器选型:IK 与 HanLP 在分布式环境下的取舍
朴素贝叶斯本身的实现并不复杂,真正决定分类效果的是分词器的质量和工程可用性。Hadoop 分布式环境里跑分词,首先要考虑的不是分词准确率,而是词典能不能在几百个 MapTask 里被稳定加载。
IK Analyzer 是我在课程设计和离线任务里的第一选择。它内嵌了常用中文词典,支持“ik_max_word 细粒度切分”和“ik_smart 粗粒度切分”两种模式,自带停用词典。细粒度模式会把“南京市长江大桥”切得更碎,粗粒度模式保留完整词的概率更大。文本分类场景里,粗粒度通常更稳,因为它减少低频碎片词带来的噪音。构造器的第二个布尔参数可以直接控制是否开启细粒度模式,代码里传true就是细粒度,传false就是粗粒度,改起来非常方便。
HanLP 的分词效果更好,尤其是对新词和特殊命名实体的识别,但它的模型文件通常有几十甚至上百 MB,如果每一个 Mapper 的 JVM 里都加载一份,内存压力很大。30 个 MapTask 同时跑的时候,每个占 80MB 模型,光模型就是 2.4GB。所以常见的做法是:中小语料上集群内存有限时优先 IK,语料里专业术语密集、且集群单机内存 8GB 以上时再考虑 HanLP。
4.2 Mapper 初始化时加载词典:setup 方法只执行一次,别写在 map 循环里
新手最常见的写法是每处理一行文本就 new 一个分词器,这在单机程序里能用,在 Hadoop 里会拖垮整个任务——Mapper 对每一行都要重建分词器,分词器内部的词典索引全部重新加载,CPU 和 GC 都会被白白耗尽。正确做法是在 setup 方法里初始化分词器,只做一次。
public class TrainMapper extends Mapper<LongWritable, Text, Text, LongWritable> { private Analyzer analyzer; @Override protected void setup(Context context) { // setup 只在每个 Mapper 初始化时执行一次 boolean useSmart = context.getConfiguration().getBoolean("ik.smart", true); analyzer = new IKAnalyzer(useSmart); } @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { // map 循环里只使用 analyzer,不重建 // ……分词逻辑见 3.1 } }如果你的数据里有一些特定领域的词,比如“深度学习”“生态环保”这种 IK 默认词典里没有的词,可以通过 IK 的扩展词典功能解决。把自定义词典放到 classpath 下的指定位置,打包进 fat jar 里,Mapper 启动时就能随 jar 分发到所有节点。注意自定义词典文件必须是 UTF-8 编码,没有 BOM 头,否则第一行词汇会解析失败且不报错,表现为模型里始终缺某些词的出现次数。
还有一种做法是把词典上传到 HDFS,在 setup 里用FileSystemAPI 下载到本地临时目录再加载。这种方案适合词典超过几十 MB、不想打进 jar 里的场景,但要注意每个 Mapper 都要下载一份,本地临时目录要足够大,任务结束后还要手动deleteOnExit。数据量不大的话,不建议用 HDFS 分发词典,打包进 jar 分发最快。
4.3 文档说明里必须写清楚的三个字段:输入格式、运行命令、错误日志定位
拿到“源代码+文档说明”的读者最关心三个问题:数据怎么放、命令怎么敲、任务挂了看哪里。一份合格的文档说明,不应该用一大段长文描述,三个表格就够了。
第一张表是输入格式:
| 字段 | 约定 |
|---|---|
| 训练集路径 | HDFS 上任意目录,文本文件,每行一条样本 |
| 样本格式 | 类别名 + TAB + 正文内容,TAB 仅第一个生效 |
| 类别词典 | 同一批次训练数据里,类别名必须严格一致 |
| 测试集路径 | 推荐单独建目录,不要和训练集放一起 |
第二张表是运行命令:
| 操作 | 命令 |
|---|---|
| 编译打包 | mvn clean package -DskipTests |
| 提交训练 Job1+Job2 | hadoop jar target/nb-hadoop.jar com.example.NaiveBayesTrain /input/train /output/model |
| 提交分类 Job | hadoop jar target/nb-hadoop.jar com.example.NaiveBayesPredict /input/test /output/predict /output/model |
| 查看任务日志 | yarn logs -applicationId <applicationId> |
第三张表是常见的日志报错定位:
| 日志关键词 | 可能原因 |
|---|---|
ClassNotFoundException | 依赖没打进 fat jar 或打包顺序不对 |
Input path does not exist | HDFS 路径写错,注意是 hdfs:// 还是 file:// |
Number of reduce tasks is 0 | 误设置setNumReduceTasks(0),训练阶段不能为 0 |
OutOfMemory | Mapper 或 Reducer 的堆太小,调整 mapreduce.map.memory.mb 参数 |
写文档时把这三块内容放最前面,比任何项目背景描述都管用。后面接上代码结构目录和核心类的职责说明,读者就能直接拿这份文档去复现了。
5. 避坑笔记:Hadoop 朴素贝叶斯最容易出问题的五个环节
5.1 长文本把 Mapper 拖垮:特征 ID 爆炸与内存溢出
现象:训练集里有一些超长文本,比如几百 KB 的网页转文本。任务跑起来后,Mapper 的 GC 时间越来越长,最后 OutOfMemory 崩溃,任务失败。
原因:分词器按整行内容切分,几万字的内容切出几万个词,每个词都要输出一个 Key。如果一个 MapTask 处理几十条这种长文本,输出数据量被放大几十倍,内存和网络双双扛不住。
解决:在 Mapper 处理前先对正文做长度截断,把超过 3000 字的正文截成前 3000 字。短文本分类场景里,超过这个长度对分类结果的贡献已经非常小,截断能实打实保护内存。截断逻辑放在分词之前,写成content = content.length() > 3000 ? content.substring(0, 3000) : content;。另外,确保打开了 Combiner,让 Map 端尽早合并计数器,减少落盘的数据量。
5.2 中文分词完全失灵:要么全是乱码,要么词典一个都没匹配上
现象:训练集是中文新闻,模型训练完一看输出的模型文件,词全是空格和奇怪的符号,或者像“的、了”这种停用词满天飞,自定义词一个都没出现。
原因:Windows 下开发时文本文档默认是 GBK 编码,而 IK 的词典是 UTF-8。代码在 eclipse 里运行没问题,打包到 Linux 集群后,读入的词典内容已经是乱码,分词自然失去效果。还有一种情况是自定义词典文件带了 BOM 头,被解析成词的一部分。
解决:统一编码是硬规则。项目里所有源文件、资源文件、词典文件全部设置为 UTF-8。Maven 的pom.xml里显式配置project.build.sourceEncoding为 UTF-8。打包前在 Linux 上用file -i your.dic检查一下编码,确认是charset=utf-8。如果用 IDEA 开发,设置里把全局编码和项目编码都改掉,再重新构建一次。
5.3 零概率把正确类别直接吞掉:没见过的新词汇是常态
现象:测试集里一篇新闻属于“体育”类别,但因为正文里某个词只出现在“娱乐”类别中,所有类别的概率乘积都是 0,最后随机猜了一个类别,分类结果明显离谱。
原因:词概率乘积里只要有一个词在某类别下计数为 0,整个乘积就是 0。真实语料中,测试集必然包含训练集没见过的词,这种 0 概率事件无法避免。
解决:拉普拉斯平滑,分子加 1,分母加该类别下不同词的数量。第 3.3 节的代码里已经加了平滑,这里再强调一个边界:如果你的训练集中某个类别非常小,只有 10 条语料,分词后不同词数量可能只有几百个,分母加的词数对概率影响很大,这是正常的,不要试图关掉平滑。平滑系数默认取 1 就好,更大的系数只会让所有概率往均匀方向靠,分类边界变模糊。
5.4 类别不平衡导致准确率虚高:大类舒服了,小类全军覆没
现象:训练集里“科技”类有 5 万条,“体育”类只有 200 条。模型跑完,整体准确率 92%,看起来不错;但单独看“体育”类的召回率只有 3%,几乎全部被分到“科技”类里。
原因:朴素贝叶斯用先验概率 P(C) 作为乘子,大类在训练集里出现多,先验概率天然大,分类器偏爱吃掉所有不确定性样本。整体准确率高是因为测试集本身也按同样比例分布,大类猜对了就拉高了总分。
解决:第一,训练集做分层抽样,把大类抽到和小类同数量级,纯度越高越好。第二,如果业务上小类更重要,预测阶段对每个类别的结果乘一个业务权重。第三种做法是把先验概率 P(C) 直接设成均匀分布,因为文本分类里词概率比先验更有区分力。判断你的模型是否受这个坑影响,最直接的办法是在测试集上按类别单独算准确率和召回率,而不要只看整体数字。
5.5 伪分布式能跑通、集群上必失败:依赖和临时文件没跟上
现象:代码在伪分布式模式下一切正常,提交到三台或五台机器的集群后,任务一启动 Mapper 就报ClassNotFoundException: org.wltea.analyzer.lucene.IKAnalyzer。
原因:伪分布式时 MR 跑在本地进程中,classpath 自动带上项目里的 jar 依赖;真正提交到集群后,每个 Task 只能在hadoop jar提交的那个 jar 包里找类。IK、HanLP 这些第三方库没有合并进去,自然找不到类。
解决:使用 maven-shade-plugin 把依赖打进最终 jar,构建成一个 fat jar,再提交。提交后到日志里确认加载的分词器是预期版本。如果业务里有自定义词典文件,也一并确认被打进了 jar,而不是只存在于本地目录。这条坑是分布式新手最容易忽略的地方,排错时会浪费大量时间。
6. 验证与进阶:log 变换、平滑系数、Spark MLlib 迁移值不值
模型训练完,先别急着上全量数据。手头有训练集和测试集时,我的习惯是先把训练集按类别比例切出 5% 放到验证集里,调参用验证集,最后才用真正的测试集看分。这个环节不要用 Hadoop 随机采样,因为 Hadoop 自带的采样不保证类别比例,直接用shuf或者 Python 脚本按类别抽样后再传到 HDFS,逻辑更可控。
验证时先看整体准确率,再按类别看召回率,至少跑一次混淆矩阵。文本分类里最容易踩的不是整体指标不好看,而是某些类别被“吃掉”。调参就拿第 5.3 节的平滑系数下手,默认 1 起步,往 2 调,再往 0.5 调,看验证集上的 F1 值变化。大多数中文语料里,平滑系数在 0.5 到 2 之间就能稳定收敛,再大就只伤不补了。
预测阶段建议改成输出 log 概率,即算 log(P(C)) + Σ词 log(P(w|C)),而不是把原始概率乘在一起。理由很简单:几百个词的概率连乘,结果会小到 double 都难以精确表示,下溢出直接变成 0。log 变换把乘法变成加法,数值稳定得多。第 3.3 节里已经输出 log 概率,预测端只需要查表累加。
式子上做完这些,再考虑要不要迁到 Spark MLlib。如果公司已有 Spark 集群,且你的训练集每天增长,MR 每跑一次要等两轮 MapReduce 往返落盘,而 Spark 的 NaiveBayes 支持特征向量和增量训练,开发效率高一个档次。迁移成本很低,因为朴素贝叶斯的概率模型本质没有变,只是把词频统计交给了 Spark 的 RDD 操作。小语料、课程设计、纯 Hadoop 环境里,手写 MR 依然是性价比选择。
我个人的习惯是:不管最终用不用 MR,都会把模型文件保留成 HDFS 上的纯文本格式,词条、类别、log 概率三列,方便后面用 Hive 建个外部表直接联查,也能给非技术人员一个直接能看的中间产物。这个习惯帮我在好几个项目里省掉了来回导出数据的功夫,希望也能帮你少踩一次坑。
本文还有配套的精品资源,点击获取