快速实验篇(A5)基于 MapReduce 的降水百分位数计算与干旱等级划分

小肥柴的Hadoop之旅 快速实验篇(A5)基于 MapReduce 的降水百分位数计算与干旱等级划分

    • 目录
    • 前言
    • [0. 背景知识:从绝对阈值到相对阈值](#0. 背景知识:从绝对阈值到相对阈值)
    • [1. 任务概述与目标](#1. 任务概述与目标)
    • [2. 干旱等级划分标准](#2. 干旱等级划分标准)
    • [3. MR 作业设计](#3. MR 作业设计)
      • [3.1 整体架构](#3.1 整体架构)
      • [3.2 Combiner 适用性分析](#3.2 Combiner 适用性分析)
      • [3.3 内存估算](#3.3 内存估算)
      • [3.4 为何将 A2-2 的结果作为 A5 的输入?](#3.4 为何将 A2-2 的结果作为 A5 的输入?)
    • [4. 正确理解 MapReduce 中的 `<K, V, P>` 三元组](#4. 正确理解 MapReduce 中的 <K, V, P> 三元组)
      • [4.1 分区编号 P 在数据流中的角色](#4.1 分区编号 P 在数据流中的角色)
      • [4.2 默认分区器](#4.2 默认分区器)
    • [5. MR程序原理与编码实现](#5. MR程序原理与编码实现)
      • [5.1 DroughtLevelMapper.java](#5.1 DroughtLevelMapper.java)
      • [5.2 DroughtLevelReducer.java](#5.2 DroughtLevelReducer.java)
      • [5.3 DroughtLevelDriver.java](#5.3 DroughtLevelDriver.java)
    • [6. 编译、打包、提交](#6. 编译、打包、提交)
    • [7. 预期输出与验证](#7. 预期输出与验证)
      • [7.1 控制台输出解读](#7.1 控制台输出解读)
      • [7.2 输出数据格式](#7.2 输出数据格式)
      • [7.3 Hive 加载验证](#7.3 Hive 加载验证)
    • [8. 数据可视化](#8. 数据可视化)
      • [8.1 结果数据集下载](#8.1 结果数据集下载)
      • [8.2 可视化分析](#8.2 可视化分析)
    • [9. 踩坑预案](#9. 踩坑预案)
    • [10. 拓展:SPI 标准化降水指数------从百分位数到概率论标准化](#10. 拓展:SPI 标准化降水指数——从百分位数到概率论标准化)
      • [10.1 SPI 是什么?](#10.1 SPI 是什么?)
      • [10.2 为什么不能直接算"(值 - 均值)/ 标准差"?](#10.2 为什么不能直接算“(值 - 均值)/ 标准差”?)
      • [10.3 Gamma 分布:为什么选它来拟合降水?](#10.3 Gamma 分布:为什么选它来拟合降水?)
      • [10.4 完整的 SPI 计算流程](#10.4 完整的 SPI 计算流程)
      • [10.5 百分位数方法 vs. 完整 SPI](#10.5 百分位数方法 vs. 完整 SPI)
      • [10.6 SPI 的工程应用实例](#10.6 SPI 的工程应用实例)
      • [10.7 为什么 A5 没做 SPI,留给 A11?](#10.7 为什么 A5 没做 SPI,留给 A11?)
    • [11. 阶段总结](#11. 阶段总结)

目录

前言

本文是"农业气象干旱分析"项目的第五阶段,也是实践周第一个完整的 Java MapReduce 程序(包含 Mapper 和 Reducer)。A4 阶段实际上简化了处理数据的策略,隐藏了两个进阶方案:

  • 进阶方案一:"基于降水百分位数的干旱等级划分"
  • 进阶方案二:"简易 SPI 标准化降水指数"。
    (1)A5 将进阶方案一从 SQL 片段落地为可运行的完整 MR 程序,标志着从 Hive SQL 分析向 Java 编程实现的能力跨越。
    (2)而方案二(SPI)因需要拟合 Gamma 分布并做正态标准化,超出了单次 MR 作业的能力边界,本文将在末尾章节对其理论与应用进行详细展开,供大家预习,为后续实验(A11)奠定认知基础。

0. 背景知识:从绝对阈值到相对阈值

  • A4 的局限:绝对阈值忽略气候差异

A4 阶段使用 precip < 0.5 mm 作为干旱阈值,结论是 96.25% 的站点无干旱。但这个阈值有一个隐含假设------0.5mm 对所有站点意义相同

可现实并非如此:年均降水 200mm 的干旱地区,5mm 的日降水已经是"湿润日";而年均降水 2000mm 的湿润地区,5mm 可能仍是"干旱日"。由此可知:干旱是一个相对概念,需要基于各站点自身的气候基线来定义。 那么如何定义干旱才是更加科学合理的呢?

  • 进阶方案一:基于降水百分位数的干旱等级划分

    对每个站点做如下设定:计算其历史降水分布的百分位数,然后按气象干旱等级标准(GB/T 20481-2017)划分:

    (1)特旱(≤10%)

    (2)重旱(10%-20%)

    (3)中旱(20%-30%)

    (4)轻旱(30%-40%)

    (5)无旱(>40%)

    当下执行的 A5任务,会将这一思路实现为完整的 Java MapReduce 程序。

  • 进阶方案二:简易 SPI 标准化降水指数

    做如下设定:

    (1)按月汇总降水量 → 计算各站点月度均值和标准差 → 将当月降水量减去均值后除以标准差,得到 SPI 近似值。

    (2)且SPI 的完整实现需要拟合 Gamma 分布并做正态标准化,其理论与应用将在本文末尾的拓展章节中详细阐述,实现则留给后续实验(A11)。


1. 任务概述与目标

项目 说明
定位 实践周第一个完整 Java MR 程序(Mapper + Reducer),将 A4 的拓展提示落地为可运行代码
目标 1. 编写 Mapper 解析 CSV,以 station_id 为 Key 输出 2. 编写 Reducer 计算每个站点的降水百分位数阈值 3. 按 GB/T 20481-2017 标准判定每条记录的干旱等级 4. 输出带干旱等级标签的全量记录 5.尝试对输出数据做可视化
输入 A2-2 结果,drought_cleaned(9,218,700 行,22 列 CSV)
输出 station_id \t record_date \t precip \t drought_level,以及对应可视化图
验证 统计各等级记录数占比,与理论预期对比

2. 干旱等级划分标准

干旱等级 降水百分位数范围 理论占比
特旱 ≤ 10%(P10) 10%
重旱 10% ~ 20%(P20) 10%
中旱 20% ~ 30%(P30) 10%
轻旱 30% ~ 40%(P40) 10%
无旱 > 40% 60%

【须知】:百分位数方法必然将每个站点 10% 的观测日划为"特旱",即使该站点水汽充沛。这与 A4 的绝对阈值(precip < 0.5mm)有本质区别:A4 回答"哪里降水绝对稀少",A5 回答"相对于自身历史,哪些时段降水异常偏少"。


3. MR 作业设计

在基本了解MR架构和运行逻辑的基础上,我们需要细心拆解需求,并精心设计每个环节。

3.1 整体架构

bash 复制代码
                    Map 阶段                    Shuffle              Reduce 阶段
              ┌─────────────────┐           ┌──────────┐     ┌─────────────────────┐
CSV 行 ─────→ │ 解析 station_id  │ ────────→ │ 按站点   │ ──→ │ 1. 收集全部 precip   │
              │ 输出 <id, 记录>  │           │ 分组排序 │     │ 2. 排序, 计算分位数   │
              └─────────────────┘           └──────────┘     │ 3. 遍历判定干旱等级   │
                                                             │ 4. 输出 <id, 等级>   │
                                                             └─────────────────────┘

判断只需要一个 Job 完成全部逻辑:每个站点仅 90 条记录,Reduce 端一次性收集排序即可,无需拆分为两个 Job。

3.2 Combiner 适用性分析

Combiner操作等同在Map-stage中做的一次小的reduce(规约),主要目的是减少进入shuffle环节的记录数量,以减轻作业压力。但需认识到:Combiner 仅适用于可交换可结合的聚合(COUNT、SUM、MIN、MAX 等),不适用于需要全量排序或全局状态的操作。

回到本任务中,可以判定其不适用 Combiner,分析原因如下:

  • Reducer 需要全量排序后计算百分位数,不是可交换可结合的聚合。
  • Combiner 在 Map 端预处理后,Reduce 端收到的仍是局部结果,无法正确计算分位数。
  • 即使强行使用,不仅逻辑错误,还增加了序列化/反序列化的 I/O 开销。

3.3 内存估算

【注】输入数据集为A2-2 out目录,3.4节做解释。

项目 计算过程 结果
每个站点记录数 固定 90 条
每条记录内存 date(10B) + precip(8B) + Text 对象开销(~40B) ~60 字节
每个站点状态大小 90 × 60B ~5.4 KB
Reducer 并行处理的站点 逐站处理,内存中仅保留当前站点 1 个
Reducer 内存占用 ~5.4 KB,远低于 256 MB 上限

3.4 为何将 A2-2 的结果作为 A5 的输入?

  • A2-2 做了什么:MapReduce 数据质量筛查与清洗,输出清洗后的 CSV 文件(22 列,无空值、无格式错误)。
  • A5 为什么直接用 A2-2 的输出来计算:
    (1)数据流的正确起点 :原始数据 /drought/output_a2_2 是整个项目的数据基座。A3 建 Hive 外部表指向它,A4 从 Hive 读它,A5 用 MR 直接读它。如果 A5 去读 A4 的物化表(如 dry_flag),不仅多了一次数据拷贝,而且丢失了列信息(A4 的物化表只保留 station_id、date、precip)。
    (2)避免数据拷贝,节省 HDFS 空间 :在 2GB 内存、14GB(现已扩至 28GB)磁盘的受限集群上,每复制一次 1.29 GB 都是奢侈的。直接指向 A2-2 意味着零拷贝------Map 直接从 A2-2 的 HDFS 块读取,不经任何中间表。
    (3)证明 A5 的 MR 程序有完整的 CSV 解析能力 :如果 A5 直接读 A4 的 dry_flag(三列,以 \t 分隔),Mapper 只需要简单 split("\t")。但读 A2-2 的原始 CSV(22 列,以 , 分隔),Mapper 必须正确解析列索引(cols0=station_id, cols1=date, cols5=precip)。这证明 A5 的 Mapper 具备处理原始输入格式的能力,而非依赖前序步骤的"预处理"。
    (4)保持实验链条的独立性与可复现性:任何一个后续实验(A5-A15)都可以直接从 /drought/output_a2_2 启动,不需要依赖中间物化表的存在。这符合"数据清洗一次,后续多次分析"的数据仓库最佳实践。

4. 正确理解 MapReduce 中的 <K, V, P> 三元组

教材通常说 Map 输出 <K, V>,Reduce 接收 <K, Iterable<V>>。但这个模型无法解释一个关键问题:同一个 K 的多个 Map 输出,如何被路由到正确的 Reducer?

准确的数据模型应该是 <K, V, P> 三元组,其中:

元素 含义 谁决定的?
K Mapper 的 context.write(k, v)
V Mapper 的 context.write(k, v)
P 分区编号 Partitioner.getPartition(K, V, numReduceTasks)

4.1 分区编号 P 在数据流中的角色

bash 复制代码
Map 端:
  context.write(station_id, record)
       │
       ▼
  P = Partitioner.getPartition(station_id, record, 3)   // 0, 1, 或 2
       │
       ▼
  写入环形缓冲区,按 <P, K, V> 排序(先按 P 排,再按 K 排)
       │
       ▼
  溢写到磁盘:part-r-00000  ← P=0 的数据
              part-r-00001  ← P=1 的数据
              part-r-00002  ← P=2 的数据

Shuffle 阶段:
  Reducer-0 拉取所有 Map 产生的 P=0 的分区文件
  Reducer-1 拉取所有 Map 产生的 P=1 的分区文件
  Reducer-2 拉取所有 Map 产生的 P=2 的分区文件

4.2 默认分区器

在编写MR程序的Driver时,如果为指定自定义分区器(Partitioner),MR程序会默认使用哈希分区 HashPartitioner

java 复制代码
// org.apache.hadoop.mapreduce.lib.partition.HashPartitioner
public int getPartition(K key, V value, int numReduceTasks) {
    return (key.hashCode() & Integer.MAX_VALUE) % numReduceTasks;
}

在本次实验中:同一个 station_id 的 Hash 值模 3 后始终得到相同的 P,因此该站点的全部记录必然路由到同一个 Reducer。这就是为什么 Reducer 能拿到某个站点的"所有记录",一切皆不是魔法,是哈希分区保证的;这也是为何在前置课程DSA介绍Hash时,我们会耗费大量时间让大家理解Hash思想,而非单纯的某一种或者某一些实现。

【疑问】你也许会好奇:为什么教学上常被省略?

  • 因为默认分区器按 K.hashCode() % N 决定 P,P 完全由 K 决定,看起来像是"同一个 K 自动聚到一起"。但当引入自定义分区器 (如按年份分区、按地域分区)时,P 不再由 K 唯一决定,三元组 <K, V, P> 的独立性就显性化了。

  • 【拓展】与后续实验中二次排序的关联。

    如果后续实验中 Key 升级为复合键 (station_id, month),且要求按 station_id 分区、按 station_id + month 排序,就需要:

    (1)自定义 Partitioner :只基于 station_id 计算 P(忽略 month)

    (2)自定义 GroupingComparator :只基于 station_id 判断是否属于同一组

    此时 <K, V, P> 三元组的 P 和 K 的排序键是不同的维度 :P 只看 station_id,而 K 的排序看 station_id + month;若仍用二元组思维,这一区别难以解释清楚。

5. MR程序原理与编码实现

【注】依旧是生成完整Maven工程,注意根据实际情况修改两个内容:

(1)编译打包的jar包名称(仅做参考,可自定义):

xml 复制代码
<artifactId>drought-mr-a5</artifactId>

(2)对应的main函数类也需要调整(仅做参考,可自定义,注意包名要以实际工程结构相符):

xml 复制代码
<manifest>
    <mainClass>com.lab.a5.DroughtLevelDriver</mainClass>
</manifest>

(3)注意包名!!!

(4)MapReduce 键值对数据转化简图

  • Map 端:
bash 复制代码
[IN]  <k1, v1>                        [OUT] <k2, v2>
       ↑                                        ↑
  LongWritable, Text                       Text, Text
       │                                        │
   文件偏移量, 一行 CSV                 station_id, "date\tprecip"
       │                                        │
   例: <0, "station_x,2012-04-03,...,3.21,...">  →  <station_x, "2012-04-03\t3.21">
  • Shuffle 阶段(隐式,框架自动完成):
bash 复制代码
<k2, v2> 三元组 <k2, v2, P=HashPartitioner(k2, 3)>
    │
    ├─→ P=0 的所有 k2/v2 ──→ Reducer-0
    ├─→ P=1 的所有 k2/v2 ──→ Reducer-1
    └─→ P=2 的所有 k2/v2 ──→ Reducer-2
  • Reduce 端:
bash 复制代码
[IN]  <k3, List<v3>>                 [OUT] <k4, v4>
       ↑                                        ↑
     Text, Iterable<Text>                  Text, Text
       │                                        │
 station_id, [全部 date\tprecip]       station_id, "date\tprecip\tdrought_level"
       │                                        │
   例: <station_x, ["2012-04-03\t3.21",      →  <station_x, "2012-04-03\t3.21\t轻旱">
                  "2012-04-04\t8.45", ...]>      <station_x, "2012-04-04\t8.45\t无旱">
                                                  ...
复制代码
k3 = k2(同一个 station_id),由 Shuffle 阶段按 P 分区后聚合
v3 = 该站点全部记录的迭代器(Iterable<Text>),Hadoop 复用同一个 Text 对象以减少 GC
k4 = station_id(Text),与 k3 相同
v4 = "date\tprecip\tdrought_level"(Text),由 Reducer 拼接

简图汇总:

bash 复制代码
Map 输入 (k1,v1)           Map 输出 (k2,v2)          Reduce 输入 (k3,List<v3>)    Reduce 输出 (k4,v4)
┌──────────────┐     ┌──────────────────────┐     ┌──────────────────────┐     ┌──────────────────────────────┐
│<偏移量,CSV行> │ ──→ │ <station_id,         │ ──→ │ <station_id,         │ ──→ │ <station_id,                 │
│              │     │  "date\tprecip">     │     │  ["date\tprecip",     │     │  "date\tprecip\tdrought_lvl">│
└──────────────┘     └──────────────────────┘     │   "date\tprecip",...] │     └──────────────────────────────┘
                                                   └──────────────────────┘
     20 个 Map                                         102,430 个分组                         102,430 站 × 90 天
   9.2M 行输入                                          3 个 Reducer                          9.2M 行输出
  • Shuffle 阶段的排序与文件合并机制

MapReduce 的 Shuffle 阶段涉及多次排序和合并,理解这一机制是掌握 MR 性能调优的关键;但毕竟是实验项目,咱们仅做简单的说明;参考下图理解(官方流程解释)。

  • Map 端
    (1)内存缓冲 :Map 输出先写入环形缓冲区(默认 100 MB)
    (2) 溢写排序 :缓冲区达到 80% 阈值时,后台线程开始溢写(Spill)到磁盘。溢写前按 <P, K> 复合键做快速排序 ------先按分区编号排序,再按键排序
    (3)多次溢写合并 :Map 完成后,所有溢写文件通过多路归并排序合并为一个大文件,最终输出一个按分区号分割的有序文件
  • Reduce 端
    (1)Copy 阶段 :从各 Map 节点拉取属于自己的分区数据(由 <K, V, P> 中的 P 决定)
    (2)内存缓冲 + 磁盘溢写 :拉取的数据先放内存,满了溢写到磁盘
    (3)多路归并合并 :所有数据拉齐后,将内存和磁盘上的多个文件做多路归并排序 ,合并为一个有序流
    (4)送入 Reduce :按 Key 分组,依次调用 reduce() 函数

5.1 DroughtLevelMapper.java

java 复制代码
import java.io.IOException;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;

/**
 * Mapper:解析 CSV 行,提取 station_id、record_date、precip。
 * Key = station_id,Value = record_date + "\t" + precip
 */
public class DroughtLevelMapper extends Mapper<LongWritable, Text, Text, Text> {

    // 对象复用:Mapper 的 map() 每行调用一次,复用 Key/Value 对象避免频繁创建
    private final Text outKey = new Text();
    private final Text outValue = new Text();

    @Override
    protected void map(LongWritable key, Text value, Context context)
            throws IOException, InterruptedException {

        String line = value.toString();
        String[] cols = line.split(",", -1);

        // CSV 列索引(与 A3 建表时的字段顺序一致)
        String stationId = cols[0];       // station_id
        String recordDate = cols[1];      // record_date
        String precipStr = cols[5];       // precip (PRECTOT)

        // 跳过空值
        if (precipStr == null || precipStr.isEmpty()) {
            return;
        }

        outKey.set(stationId);
        outValue.set(recordDate + "\t" + precipStr);
        context.write(outKey, outValue);
        // 注意:context.write() 后,框架立即序列化输出,
        // 后续对 outKey/outValue 的覆写不会污染已输出的数据
    }
}

5.2 DroughtLevelReducer.java

java 复制代码
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Reducer;

/**
 * Reducer:收集每个站点的全部降水记录,排序计算百分位数阈值,
 * 然后遍历每条记录判定干旱等级并输出。
 */
public class DroughtLevelReducer extends Reducer<Text, Text, Text, Text> {

    // 对象复用:避免在迭代中反复创建对象
    private final Text outValue = new Text();

    @Override
    protected void reduce(Text key, Iterable<Text> values, Context context)
            throws IOException, InterruptedException {

        List<Record> records = new ArrayList<>(90);    // 预分配合适容量
        List<Double> precipList = new ArrayList<>(90); // 仅用于排序计算分位数

        // ==================== 第一遍迭代:收集全部记录 ====================
        // 【重要】Hadoop 的 Iterable<Text> 迭代器复用同一个 Text 对象
        // 每次 next() 返回的是同一个引用,只是内容被新值覆盖。
        // 因此必须在循环内做深拷贝(new Text(value.toString()) 再解析),
        // 否则 ArrayList 里存的全是同一个对象,最终所有记录的值都一样。
        // 这种对象复用是 Hadoop 减少 GC 压力的核心优化------如果框架为每条
        // 记录创建新对象,9.2M 行数据将产生巨量临时对象,频繁触发 GC。
        for (Text value : values) {
            String[] parts = value.toString().split("\t", -1);
            if (parts.length < 2) continue;

            String date = parts[0];
            double precip = Double.parseDouble(parts[1]);

            records.add(new Record(date, precip));
            precipList.add(precip);
        }

        int n = precipList.size();
        if (n == 0) return;

        // ==================== 计算百分位数阈值 ====================
        Collections.sort(precipList);

        // 线性插值法计算百分位数
        // P_k = 排序后第 (n-1)*k 个位置的值
        double p10 = percentile(precipList, n, 0.10);  // 特旱上限
        double p20 = percentile(precipList, n, 0.20);  // 重旱上限
        double p30 = percentile(precipList, n, 0.30);  // 中旱上限
        double p40 = percentile(precipList, n, 0.40);  // 轻旱上限

        // ==================== 第二遍遍历:判定等级并输出 ====================
        for (Record r : records) {
            String level = classify(r.precip, p10, p20, p30, p40);
            outValue.set(r.date + "\t" + r.precip + "\t" + level);
            context.write(key, outValue);
        }
    }

    /**
     * 线性插值法计算第 k 百分位数
     * @param sorted 已排序的数值列表
     * @param n      列表大小
     * @param k      百分位数(0.0 ~ 1.0)
     * @return 第 k 百分位数值
     */
    private double percentile(List<Double> sorted, int n, double k) {
        double pos = (n - 1) * k;
        int lower = (int) Math.floor(pos);
        int upper = (int) Math.ceil(pos);
        if (lower == upper) {
            return sorted.get(lower);
        }
        double frac = pos - lower;
        return sorted.get(lower) * (1 - frac) + sorted.get(upper) * frac;
    }

    /**
     * 根据降水百分位数划分干旱等级
     */
    private String classify(double precip, double p10, double p20, 
                            double p30, double p40) {
        if (precip <= p10) return "特旱";
        if (precip <= p20) return "重旱";
        if (precip <= p30) return "中旱";
        if (precip <= p40) return "轻旱";
        return "无旱";
    }

    /**
     * 内部类:存储一条观测记录(日期 + 降水量)
     */
    private static class Record {
        final String date;
        final double precip;

        Record(String date, double precip) {
            this.date = date;
            this.precip = precip;
        }
    }
}

5.3 DroughtLevelDriver.java

java 复制代码
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import org.apache.hadoop.util.Tool;
import org.apache.hadoop.util.ToolRunner;

/**
 * Driver:配置并提交 MapReduce 作业。
 * 使用 ToolRunner 处理命令行参数,支持 -D 参数覆盖配置。
 */
public class DroughtLevelDriver extends ToolRunner implements Tool {

    private Configuration conf;

    @Override
    public int run(String[] args) throws Exception {
        if (args.length < 2) {
            System.err.println("Usage: DroughtLevelDriver <input_path> <output_path>");
            System.exit(1);
        }

        Job job = Job.getInstance(getConf(), "Drought Level Classification");

        job.setJarByClass(DroughtLevelDriver.class);

        // Mapper / Reducer
        job.setMapperClass(DroughtLevelMapper.class);
        job.setReducerClass(DroughtLevelReducer.class);

        // 输出 Key/Value 类型
        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(Text.class);

        // Map 输出类型(与 Reducer 相同)
        job.setMapOutputKeyClass(Text.class);
        job.setMapOutputValueClass(Text.class);

        // Reducer 数量:3 个(与集群节点数匹配,均衡负载)
        job.setNumReduceTasks(3);

        // 输入输出路径
        FileInputFormat.addInputPath(job, new Path(args[0]));
        FileOutputFormat.setOutputPath(job, new Path(args[1]));

        boolean success = job.waitForCompletion(true);
        return success ? 0 : 1;
    }

    @Override
    public void setConf(Configuration conf) {
        this.conf = conf;
    }

    @Override
    public Configuration getConf() {
        return this.conf;
    }

    public static void main(String[] args) throws Exception {
        int exitCode = ToolRunner.run(new Configuration(), new DroughtLevelDriver(), args);
        System.exit(exitCode);
    }
}

6. 编译、打包、提交

给出工程结构参考

打包方式参考A2-1操作,之后将目标jar包传送到master节点中,并执行如下命令(仅做参考)提交作业:

bash 复制代码
hadoop jar drought-mr-a5-1.0.jar /drought/output_a2_2 /drought/output_a5

7. 预期输出与验证

7.1 控制台输出解读

之前A2-1和A2-2中,虽然我们编写了Map-Only的MR程序,但对控制台输出的理解是不足的,即便在A4中也做了一些分析(且还发现了很多细节,给大家提供了深入学习和研究的切入点),现对A5的MR程序运行过程的控制台输出日志做较为细致的解读,以下是实际输出结果:

bash 复制代码
hadoop@master:~$ ls
data_org  derby.log  drought-mr-1.0.jar  drought-mr-A21-1.0.jar  drought-mr-a5-1.0.jar  hadoop-3.3.6.tar.gz  hive_metastore  metastore_db  new_keys.txt  old_keys.txt  test_set.csv
hadoop@master:~$ hadoop jar drought-mr-a5-1.0.jar /drought/output_a2_2 /drought/output_a5
2026-06-01 22:17:37,069 INFO client.DefaultNoHARMFailoverProxyProvider: Connecting to ResourceManager at master/192.168.10.101:8032
2026-06-01 22:17:37,928 INFO mapreduce.JobResourceUploader: Disabling Erasure Coding for path: /tmp/hadoop-yarn/staging/hadoop/.staging/job_1780323203060_0001
2026-06-01 22:17:39,818 INFO input.FileInputFormat: Total input files to process : 20
2026-06-01 22:17:40,472 INFO mapreduce.JobSubmitter: number of splits:20
2026-06-01 22:17:41,030 INFO mapreduce.JobSubmitter: Submitting tokens for job: job_1780323203060_0001
2026-06-01 22:17:41,031 INFO mapreduce.JobSubmitter: Executing with tokens: []
2026-06-01 22:17:41,659 INFO conf.Configuration: resource-types.xml not found
2026-06-01 22:17:41,660 INFO resource.ResourceUtils: Unable to find 'resource-types.xml'.
2026-06-01 22:17:42,297 INFO impl.YarnClientImpl: Submitted application application_1780323203060_0001
2026-06-01 22:17:42,365 INFO mapreduce.Job: The url to track the job: http://master:8088/proxy/application_1780323203060_0001/
2026-06-01 22:17:42,366 INFO mapreduce.Job: Running job: job_1780323203060_0001
2026-06-01 22:17:55,641 INFO mapreduce.Job: Job job_1780323203060_0001 running in uber mode : false
2026-06-01 22:17:55,644 INFO mapreduce.Job:  map 0% reduce 0%
2026-06-01 22:18:06,828 INFO mapreduce.Job:  map 5% reduce 0%
2026-06-01 22:18:11,901 INFO mapreduce.Job:  map 10% reduce 0%
2026-06-01 22:18:12,916 INFO mapreduce.Job:  map 15% reduce 0%
2026-06-01 22:18:13,927 INFO mapreduce.Job:  map 25% reduce 0%
2026-06-01 22:18:16,948 INFO mapreduce.Job:  map 30% reduce 0%
2026-06-01 22:18:25,049 INFO mapreduce.Job:  map 35% reduce 0%
2026-06-01 22:18:26,057 INFO mapreduce.Job:  map 50% reduce 0%
2026-06-01 22:18:32,113 INFO mapreduce.Job:  map 55% reduce 6%
2026-06-01 22:18:33,119 INFO mapreduce.Job:  map 60% reduce 6%
2026-06-01 22:18:34,125 INFO mapreduce.Job:  map 65% reduce 6%
2026-06-01 22:18:37,142 INFO mapreduce.Job:  map 70% reduce 6%
2026-06-01 22:18:38,148 INFO mapreduce.Job:  map 70% reduce 7%
2026-06-01 22:18:39,154 INFO mapreduce.Job:  map 75% reduce 7%
2026-06-01 22:18:40,163 INFO mapreduce.Job:  map 80% reduce 7%
2026-06-01 22:18:41,171 INFO mapreduce.Job:  map 85% reduce 7%
2026-06-01 22:18:44,192 INFO mapreduce.Job:  map 85% reduce 19%
2026-06-01 22:18:45,199 INFO mapreduce.Job:  map 95% reduce 19%
2026-06-01 22:18:46,210 INFO mapreduce.Job:  map 100% reduce 19%
2026-06-01 22:18:50,241 INFO mapreduce.Job:  map 100% reduce 52%
2026-06-01 22:18:55,287 INFO mapreduce.Job:  map 100% reduce 59%
2026-06-01 22:18:56,294 INFO mapreduce.Job:  map 100% reduce 67%
2026-06-01 22:19:02,336 INFO mapreduce.Job:  map 100% reduce 100%
2026-06-01 22:19:03,364 INFO mapreduce.Job: Job job_1780323203060_0001 completed successfully
2026-06-01 22:19:03,514 INFO mapreduce.Job: Counters: 55
        File System Counters
                FILE: Number of bytes read=356182072
                FILE: Number of bytes written=718735790
                FILE: Number of read operations=0
                FILE: Number of large read operations=0
                FILE: Number of write operations=0
                HDFS: Number of bytes read=1291089260
                HDFS: Number of bytes written=402275536
                HDFS: Number of read operations=75
                HDFS: Number of large read operations=0
                HDFS: Number of write operations=6
                HDFS: Number of bytes read erasure-coded=0
        Job Counters 
                Launched map tasks=20
                Launched reduce tasks=3
                Other local map tasks=10
                Data-local map tasks=10
                Total time spent by all maps in occupied slots (ms)=333508
                Total time spent by all reduces in occupied slots (ms)=163008
                Total time spent by all map tasks (ms)=166754
                Total time spent by all reduce tasks (ms)=81504
                Total vcore-milliseconds taken by all map tasks=166754
                Total vcore-milliseconds taken by all reduce tasks=81504
                Total megabyte-milliseconds taken by all map tasks=85378048
                Total megabyte-milliseconds taken by all reduce tasks=41730048
        Map-Reduce Framework
                Map input records=9218700
                Map output records=9218700
                Map output bytes=337744636
                Map output materialized bytes=356182396
                Input split bytes=2350
                Combine input records=0
                Combine output records=0
                Reduce input groups=102430
                Reduce shuffle bytes=356182396
                Reduce input records=9218700
                Reduce output records=9218700
                Spilled Records=18437400
                Shuffled Maps =60
                Failed Shuffles=0
                Merged Map outputs=60
                GC time elapsed (ms)=5938
                CPU time spent (ms)=99010
                Physical memory (bytes) snapshot=6226972672
                Virtual memory (bytes) snapshot=44322197504
                Total committed heap usage (bytes)=4346347520
                Peak Map Physical memory (bytes)=269930496
                Peak Map Virtual memory (bytes)=1933348864
                Peak Reduce Physical memory (bytes)=327933952
                Peak Reduce Virtual memory (bytes)=1938952192
        Shuffle Errors
                BAD_ID=0
                CONNECTION=0
                IO_ERROR=0
                WRONG_LENGTH=0
                WRONG_MAP=0
                WRONG_REDUCE=0
        File Input Format Counters 
                Bytes Read=1291086910
        File Output Format Counters 
                Bytes Written=402275536

上述log解读如下:

  • 前期准备(日志:00:17:37 - 00:17:42)
bash 复制代码
Connecting to ResourceManager at master/192.168.10.101:8032

【解读】:YARN 客户端(你提交命令的这台机器,譬如master)向 YARN 的 ResourceManager(RM,资源管理器)发起连接。这是 MR 作业的"挂号"步骤,而 RM 是整个集群的资源大管家。

bash 复制代码
Total input files to process : 20
number of splits:20

【解读】:HDFS 输入目录下有 20 个文件(A2-2 清洗输出的 part-m-xxxxx),每个文件对应 1 个 Map 任务。因为 FileInputFormat 默认每个文件至少 1 个 Split。这意味着本次作业将启动 20 个 Map 任务来处理全部数据。

bash 复制代码
Submitted application application_1780323203060_0001
Running job: job_1780323203060_0001

【解读】:作业提交成功,获得了 YARN 分配的 Application ID,状态为"运行中"。

bash 复制代码
Job job_1780323203060_0001 running in uber mode : false

【解读】

uber mode 是 YARN 的优化:如果作业很小(少量 Map、少量数据),YARN 会让所有任务在同一个容器(Container)内串行执行,避免申请多个容器的开销。这里 false 意味着作业数据量较大(1.29 GB,20 个 Map),YARN 判断不宜使用 uber 模式,转而使用标准的分布式模式,即每个 Map/Reduce 独立申请容器运行。

  • Map 阶段(日志:00:18:06 - 00:18:46)
bash 复制代码
map 5% reduce 0%
map 10% reduce 0%
...
map 55% reduce 6%     ← 慢启动!

这不就是A4 实验中详述的慢启动机制(Slow Start)嘛。Map 跑到 55% 时,Reduce 已经有 6% 的进度了。这 6% 不是计算,而是 Reduce 的 Copy 阶段------在从已完成 Map 任务的分区文件中拉取属于自己的 <station_id, record> 数据。Copy 只是网络搬运,不涉及百分位数计算,可以与 Map 完全并行。

Map 端数据流:

bash 复制代码
Split-0 ──→ Map Task ──→ 输出 <station_id, "date\tprecip">
Split-1 ──→ Map Task ──→ 输出 <station_id, "date\tprecip">
  ...
Split-19 ─→ Map Task ──→ 输出 <station_id, "date\tprecip">

每个 Map 任务的内部流程:

(1)读取 Split 中的每行 CSV。

(2)DroughtLevelMapper.map() 解析出 station_id、record_date、precip。

(3)输出 <Text(station_id), Text("date\tprecip")>。

(4)HashPartitioner 根据 station_id.hashCode() % 3 将输出分区为 3 份(对应 3 个 Reducer)。

(5)Map 端对输出做 <P, K> 排序(先按分区号,再按站点 ID),溢写到磁盘。

(6)所有 Map 完成后,通过多路归并合并为一个大文件。

  • Reduce 阶段(日志:00:18:50 - 00:19:02)
bash 复制代码
map 100% reduce 19%
map 100% reduce 52%
map 100% reduce 59%
map 100% reduce 67%
map 100% reduce 100%

Map 100% 后,Reduce 进度从 19% 跳升到 100%,这是典型的 Sort + Reduce 阶段。Copy 拉齐了全部数据后,Reducer 开始做:

(1)多路归并排序:将从 20 个 Map 拉来的 60 个分区文件(20 Map × 3 Reducer),合并为 1 个按 station_id 排序的数据流。

(2)分组:将同一 station_id 的所有记录聚合成 Iterable 。
(3)调用 reduce():依次处理每组(共 102,430 组)
i. 第一遍迭代:将 90 条 precip 值收集到 ArrayList 并排序。
ii. 计算 P10/P20/P30/P40 四个百分位数阈值。
iii. 第二遍遍历:逐条判定干旱等级并输出。

  • Counters(计数器)深度解读
bash 复制代码
Map input records=9218700      ← 总行数(9.2M),与 A3 COUNT(*) 一致
Map output records=9218700     ← Map 无过滤,每行都输出
Reduce input groups=102430     ← 站点总数,与 A4 station_drought_profile 行数一致
Reduce output records=9218700  ← 输出总行数,与输入一致

验证通过:输入 9,218,700 行 → 输出 9,218,700 行,没有数据丢失;102,430 个分组,每个站点都被处理。

bash 复制代码
Reduce shuffle bytes=356182396    ← 约 340 MB
Reduce input records=9218700      ← 9.2M 条,与 Map 输出一致
Spilled Records=18437400          ← 溢写约 18.4M 条(Map + Reduce 端合计)
Shuffled Maps =60                 ← 3 Reducer × 20 Map = 60 个分区文件
Merged Map outputs=60             ← 同上,多路归并合并

【关键观察】

(1)Map output bytes(约 338 MB)大于 HDFS 最终输出(约 402 MB),这是因为 Map 输出经过压缩和序列化,而最终输出是未压缩的文本。

(2)Spilled Records=18.4M 恰好是 9.2M × 2------Map 端溢写 9.2M + Reduce 端溢写 9.2M,说明数据刚好跨过了内存缓冲阈值,触发了磁盘溢写,但 14 GB 可用空间完全从容应对。

【内存评估】:单 Reducer 峰值物理内存仅 313 MB,低于容器的 384 MB 上限,没有触发任何 OOM 或 Container killed;与我们在 A3 中建立的"384 MB 容器 / 256 MB JVM 堆"内存模型再次得到验证。

7.2 输出数据格式

复制代码
station_id              record_date  precip  drought_level
-1000799230488340175    2012-04-03   3.21    轻旱
-1000799230488340175    2012-04-04   8.45    无旱
6812413573084174289     2014-03-15   0.12    特旱
...

7.3 Hive 加载验证

sql 复制代码
-- 创建外部表指向 A5 输出
CREATE EXTERNAL TABLE drought_levels_a5 (
    station_id   STRING,
    record_date  STRING,
    precip       DOUBLE,
    drought_level STRING
)
ROW FORMAT DELIMITED
FIELDS TERMINATED BY '\t'
STORED AS TEXTFILE
LOCATION '/drought/output_a5';

-- 验证:各等级记录数占比(应与理论值 10%/10%/10%/10%/60% 接近)
SELECT 
    drought_level,
    COUNT(*) AS cnt,
    ROUND(COUNT(*) / 9218700.0 * 100, 2) AS pct
FROM drought_levels_a5
GROUP BY drought_level
ORDER BY 
    CASE drought_level
        WHEN '特旱' THEN 1
        WHEN '重旱' THEN 2
        WHEN '中旱' THEN 3
        WHEN '轻旱' THEN 4
        WHEN '无旱' THEN 5
    END;
  • 理论预期
干旱等级 理论占比 实际预期偏差
特旱 10.00% ±0.02%
重旱 10.00% ±0.02%
中旱 10.00% ±0.02%
轻旱 10.00% ±0.02%
无旱 60.00% ±0.02%
  • 偏差来源:每个站点仅 90 条记录,百分位数边界处可能有少量记录因相同降水值分配到同侧,导致微小偏差。这是样本量限制的正常现象,不影响结论的有效性。

  • 与 A4 的交叉对比

方法 阈值逻辑 干旱站点/记录占比
A4 绝对阈值 precip < 0.5mm 3.75% 站点有干旱日
A5 百分位数 各站点最低 10% 降水 ~10% 记录标记为特旱
  • 两者不矛盾:A4 找"绝对稀少的降水",A5 找"相对于自身气候的异常偏少"。同一站点可能在 A4 中"无干旱"(因为降水绝对值不低),但在 A5 中被标记为"特旱"(因为相对于该站点自身历史,这段时间的降水确实异常偏少)。

8. 数据可视化

8.1 结果数据集下载

VM 可能没有图形界面,直接在里面做可视化比较困难;正确的流程是:HDFS → master 本地 → Windows 主机 → Python 可视化。

  • step 1:将 HDFS 输出合并下载到 master 本地。

HDFS 上的 A5 输出是 3 个 part-r-0000x 文件。我们需要把它们合并成一个完整的文本文件,然后拉到本地。

bash 复制代码
# 在 master 上执行
# 1. 合并三个 Reducer 输出为一个文件
hdfs dfs -getmerge /drought/output_a5/part-r-* /home/hadoop/a5_drought_output.txt

# 2. 查看行数确认完整性(应为 9,218,700)
wc -l /home/hadoop/a5_drought_output.txt

# 3. 查看前几行确认格式
head -5 /home/hadoop/a5_drought_output.txt

【解释】:

(1)getmerge 是 HDFS 命令,将目录下所有匹配的文件按顺序拼接为一个本地文件。

(2)输出文件大小约 402 MB(与 Counters 中的 HDFS: Number of bytes written=402275536 一致)。

  • step 2:下载A4中的物化结果。
    在master上进入hive,执行如下命令:
sql 复制代码
-- 设置参数(和之前一样)
SET hive.execution.engine=mr;
SET mapreduce.map.memory.mb=384;
SET mapreduce.map.java.opts=-Xmx256m;
SET mapreduce.reduce.memory.mb=384;
SET mapreduce.reduce.java.opts=-Xmx256m;
SET mapreduce.job.reduces=1;

-- 导出 station_drought_profile 表数据到本地
INSERT OVERWRITE LOCAL DIRECTORY '/home/hadoop/a4_export'
ROW FORMAT DELIMITED
FIELDS TERMINATED BY '\t'
SELECT * FROM station_drought_profile;

完事 "quit;" 退出hive。这个命令会把全站点干旱画像表(102,430 行)导出到 /home/hadoop/a4_export/ 目录。因为输出目录只能有一个文件,可能会生成类似 000000_0 这样的文件名。为方便使用,可以将其重命名:

bash 复制代码
# 查看导出的文件
ls /home/hadoop/a4_export/

# 合并为一个文件并重命名(如果只有一个文件,直接重命名即可)
cat /home/hadoop/a4_export/* > /home/hadoop/a4_drought_profile.txt
  • step 3:将文件从 master 传输到 Windows 主机,注意事项如下:
    (1)从HDFS/Hive上下载文件后,可以使用ls命令查看当前操作的vm目录下是否已经真正完成下载。
    (2)在shell软件中尝试下载该文件前,应该刷新页面,否则可能看不到该文件。

【注】上传和下载操作自己看UI页面,A2已经差不多熟悉了。

8.2 可视化分析

输出文件内容(行数)较多,加载过程比较费事,这里我们主要对几个重点指标做可视化,具体说明参考注释,且在可视化过程中,需要根据实际数据显示情况调整展示方案,但我的建议是不要犟,AI十分擅长这些操作。

  • 完整程序:使用AI辅助完成,prompt时需要注意解耦、设定进程提示信息,留意公共资源加载过程是否超时等。
python 复制代码
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib
import time
matplotlib.rcParams['font.sans-serif'] = ['SimHei', 'DejaVu Sans']
matplotlib.rcParams['axes.unicode_minus'] = False

# ====================================================================
#                          公共变量区
# ====================================================================

LEVEL_ORDER = ['特旱', '重旱', '中旱', '轻旱', '无旱']
LEVEL_COLORS = ['#8B0000', '#CD5C5C', '#F4A460', '#FFD700', '#90EE90']

# ------ A5 数据加载 ------
print("=" * 60)
print("正在加载 A5 实验数据(约 9.2M 行,预计需要 30-60 秒)...")
t_load_start = time.time()

a5_cols = ['station_id', 'record_date', 'precip', 'drought_level']
df = pd.read_csv('a5_drought_output.txt', sep='\t', names=a5_cols,
                 dtype={'station_id': str})
print(f"  → A5 原始数据读取完成(耗时 {time.time() - t_load_start:.1f} 秒)")

# 数据预处理
print("正在进行数据预处理(日期解析、衍生列计算)...")
t_prep_start = time.time()

df['record_date'] = pd.to_datetime(df['record_date'])
df['year'] = df['record_date'].dt.year
df['month'] = df['record_date'].dt.month
df['is_severe'] = df['drought_level'].isin(['特旱', '重旱'])
df['year_month'] = df['record_date'].dt.to_period('M')

print(f"  → A5 数据预处理完成(耗时 {time.time() - t_prep_start:.1f} 秒)")
print(f"  → A5 数据总加载时间:{time.time() - t_load_start:.1f} 秒")
print("=" * 60)

# ------ A4 数据加载 ------
print("=" * 60)
print("正在加载 A4 实验数据(全站点干旱画像表)...")
t_a4_start = time.time()

a4_cols = ['station_id', 'dry_event_count', 'max_dry_days', 'avg_dry_days', 'total_dry_days']
a4_df = pd.read_csv('a4_drought_profile.txt', sep='\t', names=a4_cols)

print(f"  → A4 数据加载完成,{len(a4_df)} 行(耗时 {time.time() - t_a4_start:.1f} 秒)")
print("=" * 60)


# ====================================================================
#         图1:干旱等级分布验证 + Top 20 最干旱站点
# ====================================================================
def make_figure_1():
    print("开始生成图1:干旱等级分布 + Top 20 最干旱站点...")
    t0 = time.time()

    fig, axes = plt.subplots(1, 2, figsize=(14, 5))

    # --- 左侧:干旱等级分布 ---
    level_counts = df['drought_level'].value_counts()
    level_pct = (level_counts / len(df) * 100).reindex(LEVEL_ORDER)

    axes[0].bar(LEVEL_ORDER, level_pct, color=LEVEL_COLORS, edgecolor='black')
    axes[0].set_ylabel('占比 (%)')
    axes[0].set_title('干旱等级分布(A5)')
    for i, v in enumerate(level_pct):
        axes[0].text(i, v + 0.3, f'{v:.1f}%', ha='center', fontsize=10, fontweight='bold')
    axes[0].axhline(y=10, color='gray', linestyle='--', alpha=0.5, label='理论值 10%')
    axes[0].axhline(y=60, color='gray', linestyle='--', alpha=0.5, label='理论值 60%')
    axes[0].legend()

    # --- 右侧:Top 20 最干旱站点 ---
    station_severity = df[df['is_severe']].groupby('station_id').agg(
        severe_avg_precip=('precip', 'mean')
    ).sort_values('severe_avg_precip', ascending=True).head(20)

    axes[1].barh(range(20), station_severity['severe_avg_precip'],
                 color='firebrick', edgecolor='black')
    axes[1].set_yticks(range(20))
    axes[1].set_yticklabels(station_severity.index.str[:12], fontsize=8)
    axes[1].set_xlabel('特旱+重旱期间平均降水量 (mm)')
    axes[1].set_title('Top 20 最干旱站点(按严重干旱期平均降水量)')
    axes[1].invert_yaxis()

    plt.tight_layout()
    plt.savefig('a5_drought_distribution.png', dpi=150, bbox_inches='tight')
    plt.close()

    elapsed = time.time() - t0
    print(f"  → 图1生成完成,已保存为 a5_drought_distribution.png(耗时 {elapsed:.1f} 秒)")


# ====================================================================
#               图2:年度堆叠柱状图 + 月度干旱强度热力图
# ====================================================================
def make_figure_2():
    print("开始生成图2:年度堆叠柱状图 + 月度干旱强度热力图...")
    t0 = time.time()

    fig, axes = plt.subplots(2, 1, figsize=(14, 8))

    # --- 上半:年度堆叠柱状图 ---
    yearly_level = df.groupby(['year', 'drought_level']).size().unstack(fill_value=0)
    yearly_level = yearly_level[LEVEL_ORDER]

    axes[0].bar(yearly_level.index, yearly_level['无旱'], color='#90EE90', label='无旱')
    axes[0].bar(yearly_level.index, yearly_level['轻旱'],
                bottom=yearly_level['无旱'], color='#FFD700', label='轻旱')
    bottom_mid = yearly_level['无旱'] + yearly_level['轻旱']
    axes[0].bar(yearly_level.index, yearly_level['中旱'],
                bottom=bottom_mid, color='#F4A460', label='中旱')
    bottom_severe = bottom_mid + yearly_level['中旱']
    axes[0].bar(yearly_level.index, yearly_level['重旱'],
                bottom=bottom_severe, color='#CD5C5C', label='重旱')
    bottom_extreme = bottom_severe + yearly_level['重旱']
    axes[0].bar(yearly_level.index, yearly_level['特旱'],
                bottom=bottom_extreme, color='#8B0000', label='特旱')
    axes[0].set_ylabel('记录数')
    axes[0].set_title('各年度干旱等级分布')
    axes[0].legend(loc='upper right', ncol=5)

    # --- 下半:月度严重干旱强度热力图 ---
    monthly_severe_avg = df[df['is_severe']].groupby('year_month')['precip'].mean()
    heat_data = monthly_severe_avg.reset_index()
    heat_data.columns = ['year_month', 'severe_avg_precip']
    heat_data['year'] = heat_data['year_month'].dt.year
    heat_data['month'] = heat_data['year_month'].dt.month
    pivot = heat_data.pivot(index='month', columns='year', values='severe_avg_precip')

    im = axes[1].imshow(pivot, aspect='auto', cmap='YlOrRd_r',
                        vmin=0, vmax=pivot.max().max())
    axes[1].set_xticks(range(len(pivot.columns)))
    axes[1].set_xticklabels(pivot.columns, rotation=45)
    axes[1].set_yticks(range(len(pivot.index)))
    axes[1].set_yticklabels(pivot.index)
    axes[1].set_xlabel('年份')
    axes[1].set_ylabel('月份')
    axes[1].set_title('月度严重干旱(特旱+重旱)平均降水量 (mm)')
    plt.colorbar(im, ax=axes[1], label='平均降水量 (mm)')

    plt.tight_layout()
    plt.savefig('a5_temporal_drought.png', dpi=150, bbox_inches='tight')
    plt.close()

    elapsed = time.time() - t0
    print(f"  → 图2生成完成,已保存为 a5_temporal_drought.png(耗时 {elapsed:.1f} 秒)")


# ====================================================================
#                  图3:A4 vs A5 双视角对比
# ====================================================================
def make_figure_3():
    print("开始生成图3:A4 vs A5 双视角对比...")
    t0 = time.time()

    fig, axes = plt.subplots(1, 2, figsize=(14, 5))

    # ------ A4 视角:对数尺度 ------
    a4_dist = a4_df['max_dry_days'].value_counts().sort_index()
    axes[0].bar(a4_dist.index[:15], a4_dist.values[:15],
                color='steelblue', edgecolor='black')
    axes[0].set_xlabel('最长连续干旱天数')
    axes[0].set_ylabel('站点数(对数尺度)')
    axes[0].set_title('A4:绝对阈值(precip < 0.5mm)')
    axes[0].set_yscale('log')

    max_val = a4_dist.get(0, 0)
    max_pct = max_val / len(a4_df) * 100
    axes[0].annotate(f'{max_val} 站点无干旱\n占比 {max_pct:.1f}%',
                     xy=(0, max_val), xytext=(6, max_val * 0.05),
                     arrowprops=dict(arrowstyle='->', color='red'),
                     fontsize=10, color='red')

    # ------ A5 视角 ------
    df['is_extreme'] = (df['drought_level'] == '特旱').astype(int)
    station_extreme_pct = df.groupby('station_id')['is_extreme'].mean() * 100

    axes[1].hist(station_extreme_pct, bins=40, color='firebrick',
                 edgecolor='black', alpha=0.8)
    axes[1].axvline(x=10, color='gold', linestyle='--', linewidth=2, label='理论值 10%')
    axes[1].set_xlabel('特旱记录占比 (%)')
    axes[1].set_ylabel('站点数')
    axes[1].set_title('A5:相对阈值(各站点百分位数)')
    axes[1].legend()

    plt.tight_layout()
    plt.savefig('a4_vs_a5_comparison.png', dpi=150, bbox_inches='tight')
    plt.close()

    elapsed = time.time() - t0
    print(f"  → 图3生成完成,已保存为 a4_vs_a5_comparison.png(耗时 {elapsed:.1f} 秒)")


# ====================================================================
#                         执行入口
# ====================================================================
if __name__ == '__main__':
    print("\n" + "★" * 60)
    print("     A5 干旱等级划分 - 可视化脚本开始运行")
    print("★" * 60 + "\n")

    # make_figure_1()
    # make_figure_2()
    make_figure_3()

    print("\n" + "★" * 60)
    print("     全部图表生成完毕!")
    print("★" * 60)
  • 最终效果(仅供参考)


【追问】上述可视化图表表达的信息细节,你能正确解读吗?譬如:为何第三章图的左边要用对数轴?

【进阶可视化建议】当前的可视化效果不是很高级,可以尝试依靠AI完成下列表中的改进内容,这些图表可在 A6-A15 的各阶段中根据分析目标选用。

图表类型 适用场景 Python 库
Choropleth 地图 站点空间分布 + 干旱等级(若有经纬度) geopandas + matplotlib
Ridgeline 山脊图 多个站点的降水分布曲线叠加 seaborn / joypy
动态时间序列 SPI 随时间的演变(A11 后可用) plotly / bokeh
热力日历图 一整年逐日干旱等级矩阵 calmap
弦图 干旱等级转移概率(马尔可夫链) holoviews

9. 踩坑预案

序号 潜在问题 原因分析 预案
1 ArrayList 里所有记录的 precip 值都一样 Hadoop 迭代器对象复用,没有在循环内做深拷贝 records.add(new Record(date, precip)) 已做深拷贝;建议加一条验证日志,打印首个和最后一个记录的 precip 值,确认不同
2 Reducer OOM 某站点数据异常多(应 90 天,实际可能有几千行) 虽然 A3 确认每个站点 90 行,但首次运行时加 records.size() 日志输出,超过 200 行就打印警告
3 输出文件数与 Reducer 数不一致 某些 Reducer 处理站点数过少 102,430 站点 / 3 Reducer ≈ 34,143 站点/Reducer,均匀分布,无倾斜风险
4 百分位数计算边界错误 整数索引取整导致阈值偏差 使用线性插值法 percentile(),避免整数索引取整误差
5 ToolRunner vs implements Tool 写法错误 Hadoop 3.x 中 ToolRunner.run() 需要 ConfigurationTool 实例 Driver 中 implements Tool 并实现 setConf/getConf,main 中用 ToolRunner.run(new Configuration(), new DroughtLevelDriver(), args)
6 编译时找不到 Hadoop 类 CLASSPATH 未正确设置 export HADOOP_CLASSPATH=$(hadoop classpath) 后再编译
7 输出目录已存在 MR 作业不允许覆盖已有目录 提交前 hdfs dfs -rm -r /drought/output_a5
8 Shuffle 阶段磁盘 I/O 高 Reducer 需从多个 Map 拉取数据并归并排序 本次仅 1.29 GB 输入 + 14 GB 可用空间,完全安全。但可在教学中解释溢写文件的合并过程
9 迭代器对象复用导致数据错误 学生直接在 ArrayList.add(value) 而忘记 new Text(value.toString()) 代码中已通过 value.toString().split() 做深拷贝,并在注释中解释原因

10. 拓展:SPI 标准化降水指数------从百分位数到概率论标准化

【注】这部分内容的解读,要求具备一定的统计学理论基础知识,或者已经学完/具备"数据挖掘"基本技能。

作为干旱分析领域最重要的标准化指标,SPI 的理论基础和应用价值值得深入阐述。此处将系统介绍 SPI 的原理、数学基础、工程应用,以及与 A5 百分位数方法的关系,为后续实验(A11 专项 SPI 实现)奠定认知基础。

10.1 SPI 是什么?

SPI(Standardized Precipitation Index,标准化降水指数)是世界气象组织推荐的气象干旱监测核心指标。其本质是:

将某时段(如 1 个月、3 个月、6 个月)的累积降水量,转换成一个标准正态分布的 Z 值,使得不同地点、不同时间尺度的干旱程度可以统一比较。

SPI 值是一个 Z-score,即标准正态分布下的偏离程度:

SPI 值 含义 发生概率
0 降水等于历史中位数 50%
-1.0 偏少 1 个标准差 ~16%
-1.5 中度干旱阈值 ~7%
-2.0 偏少 2 个标准差,严重干旱 ~2.3%
+1.0 偏多 1 个标准差 ~16%

对应的干旱等级(中国国标 GB/T 20481-2017):

SPI 范围 干旱等级
-0.5 < SPI 无旱
-1.0 < SPI ≤ -0.5 轻旱
-1.5 < SPI ≤ -1.0 中旱
-2.0 < SPI ≤ -1.5 重旱
SPI ≤ -2.0 特旱

10.2 为什么不能直接算"(值 - 均值)/ 标准差"?

因为降水量不服从正态分布

正态分布是对称的钟形曲线。但降水量分布是这样的,wiki链接 若不可用参考下图:

降水量的特点:

  • 非负:不能小于 0
  • 右偏:大量日子降水少(0-5mm),少数暴雨日降水极大(50mm+)
  • 方差随均值变化:降水多的站点/月份,波动也大

直接算 (X - μ) / σ 的前提是 X 近似正态分布。降水量不满足这个前提,强行标准化会扭曲干旱评估------极端偏少的信号被压制,极端偏多的信号被放大。

10.3 Gamma 分布:为什么选它来拟合降水?

Gamma 分布是一个定义在正实数域上、可以灵活调节形状的连续概率分布。它由两个参数控制:

参数 符号 含义
形状参数 α(alpha) 控制分布的形状------是单调递减(α<1)、指数形(α=1)、还是钟形(α>1)
尺度参数 β(beta) 控制分布的"伸展"程度------β 越大,分布越宽

Gamma 分布的形状极其灵活,wiki链接 若不可用参考下图:

正是这种灵活性,使得 Gamma 分布能够很好地拟合各种形状的降水分布------从极为干旱的地区(α 很小,大部分日子无雨)到湿润地区(α 较大,分布接近钟形)。它是经过 30+ 年全球气象数据验证的标准选择(McKee et al., 1993)。

10.4 完整的 SPI 计算流程

SPI 的计算分为三步,每一步都有其数学和物理意义:

复制代码
原始降水量序列(如历年 7 月降水:15, 22, 8, 31, 18, ...)
         │
         ▼
    [1] 拟合 Gamma 分布
         - 用最大似然法从数据中估计 α 和 β
         - 得到该站点/月份的降水概率分布模型
         │
         ▼
    [2] 计算累积概率
         - 对于今年 7 月降水 = 8mm
         - 计算在拟合的 Gamma 分布中,降水 ≤ 8mm 的概率
         - 例如:CDF(8) = 0.07 → 7% 的年份降水 ≤ 8mm
         │
         ▼
    [3] 正态标准化(等概率变换)
         - 在标准正态分布中,找到累积概率 = 0.07 对应的 Z 值
         - Z = Φ^(-1)(0.07) ≈ -1.48
         - 这个 Z 值就是 SPI
         │
         ▼
      SPI = -1.48 → 中旱

第一步(拟合 Gamma 分布)的意义:降水数据本身不服从正态分布,我们需要一个能够描述降水真实分布的数学模型,Gamma 分布就是为这个目的服务的。

第二步(计算累积概率)的意义:把今年的降水量代入拟合好的 Gamma 分布,得到"降水不超过这个值的概率"。这个概率是一个统一的度量------不管原始数据是什么分布,概率永远在 0, 1 之间。

第三步(正态标准化)的意义:有了概率,就可以映射到标准正态分布上。标准正态分布有一个性质:给定概率,可以唯一对应一个 Z 值。这个 Z 值就是 SPI。因为标准正态分布的均值为 0、标准差为 1,所以 SPI = -1.48 的含义是"该值偏离均值 1.48 个标准差"。这一步将千差万别的降水分布统一映射到了一个公共的"干旱语言"上。

10.5 百分位数方法 vs. 完整 SPI

A5 的百分位数方法可以看作 SPI 的一阶近似,两者的对比如下:

维度 A5 百分位数方法 完整 SPI
数据建模 离散排序,取分位点 连续 Gamma 分布拟合
标准化 站点内部比较,不跨站点标准化 正态标准化,全局可比
极端值敏感度 低(离散排序丢失尾部信息) 高(连续分布精确描述尾部)
计算复杂度 O(n log n) 排序 最大似然估计 + 数值积分
适用场景 教学演示、快速筛查 业务监测、保险精算、跨国比较

SPI 的第三步------正态标准化------是其方法论的核心。它使得一个干旱地区站点(年降水 50mm)和一个湿润地区站点(年降水 3000mm)在 SPI = -2.0 时,都可以被解读为"仅 2.3% 概率发生的极端干旱"。

10.6 SPI 的工程应用实例

  • 美国干旱监测系统(U.S. Drought Monitor):USDM 每周发布的全美干旱地图,融合了多种时间尺度的 SPI(1/3/6/12/24 个月)。USDM 的地图被美国农业部用于决定灾害援助资金的发放------某县若被标记为"D2(严重干旱)"连续 8 周,该县农户即自动获得联邦农作物保险的低息贷款资格。SPI 在这里从统计指标变成了真金白银的决策依据。

  • FAO 全球粮食安全预警系统(GIEWS):联合国粮农组织定期计算全球农业区的 SPI(主要用 3 个月和 6 个月尺度)。3 个月 SPI 对土壤湿度敏感------反映作物根系层的水分状况;6 个月 SPI 对径流和水库蓄水敏感------反映灌溉水源的丰枯。2015-2016 年厄尔尼诺事件期间,GIEWS 通过 SPI 在埃塞俄比亚、索马里等国提前 3 个月发现了严重干旱信号,协调了早期粮食援助。

  • 中国气象局干旱监测业务:国家气候中心每日接收全国 2400+ 个国家级气象站的实时降水数据,滚动计算各站不同时间尺度的 SPI,生成全国干旱分布图。2010 年西南大旱、2014 年华北夏旱期间,SPI 监测产品在旱情初现时就捕捉到了 -2.0 以下的极端信号。

  • 南非开普敦"零日"干旱:2015-2018 年,开普敦遭遇有记录以来最严重干旱,24 个月 SPI 在 2017 年初已跌至 -2.5 以下(概率约 0.6%,相当于 160 年一遇)。事后分析表明,若当时使用了 SPI 长期监测,可以提前 1-2 年启动节水措施。此事件后,南非水利部将 SPI 纳入了国家干旱监测框架。

  • 世界银行天气指数保险:在肯尼亚、埃塞俄比亚等国试点的指数保险产品中,3 个月 SPI 是触发赔付的核心指标。合同约定:若作物生长季的 SPI 低于 -1.5,所有投保农户自动获得赔付,无需现场查勘。SPI 的数学定义消除了理赔争议,且数据来源公开透明,解决了发展中国家农业保险"理赔难、成本高"的痛点。

10.7 为什么 A5 没做 SPI,留给 A11?

完整的 SPI 需要在 Reduce 端对每个站点/月份组合:

  • 最大似然法估计 Gamma 分布的 α 和 β 参数:这是一个迭代优化过程,涉及对数 Gamma 函数的求导。
  • 计算累积概率:需要数值积分或查不完全 Gamma 函数表。
  • 正态分位数变换:需要逆正态累积分布函数。

这些数学运算在 Java 中可以实现(使用 Apache Commons Math 等库),但:

  • 代码复杂度极高:需要引入第三方数学库并打包到 jar 中。
  • 计算量大:每个站点都要做迭代拟合,102,430 个站点 × 多次迭代 = 不可忽视的开销。
  • 教学节奏考虑:A5 的核心任务是"第一个完整 MR 程序",应聚焦于 MR 编程模型本身。

因此 A5 采用百分位数方法作为教学级的实现,完整的 SPI 留给 A11。相信大家那时候已熟悉 MR 编程范式,可以集中精力解决"如何在 MR 中实现 Gamma 拟合和正态标准化"这一高阶工程问题。


11. 阶段总结

  • 从 SQL 到 Java 的能力跨越:A5 是实践周第一个完整的 MapReduce 程序(A2-1 只有 Map 无 Reduce),学生首次亲手实现 Mapper → Partitioner → Shuffle → Reducer 全流程。代码从 Hive SQL 的"描述式分析"转变为 Java MR 的"过程式实现",对分布式计算的理解进入了更深的层次。

  • 干旱定义的深化:从 A4 的绝对阈值(precip < 0.5mm)进阶到 A5 的相对阈值(各站点降水百分位数),揭示了干旱是一个相对概念。百分位数方法确保每个站点 10% 的观测日被标为特旱,与 A4 的"96% 站点无干旱"形成互补视角。

  • <K, V, P> 三元组认知:修正了教材中常见的"键值对"简化表述,明确了分区编号 P 在 Map 端排序、Shuffle 路由中的核心作用,为后续自定义分区器和二次排序打下理论基础。

  • 对象复用与内存优化 :通过代码注释和讲解,说明了 Hadoop 迭代器对象复用的原理和必要性------减少 GC 压力,提升吞吐。同时强调了深拷贝的重要性------不复制的后果是 ArrayList 里全是同一个对象的最后一条值。

  • Combiner 适用性判断:明确了 Combiner 仅适用于可交换可结合的聚合操作,不适合需要全量排序的场景。这一判断能力是 MR 性能优化的基本功。

  • Shuffle 机制全景:从 Map 端环形缓冲区 → 溢写排序 → 多路归并,到 Reduce 端 Copy → 溢写 → 归并 → 分组,完整覆盖了 Shuffle 阶段的数据流动路径,为性能调优提供了理论支撑。

  • SPI 理论铺垫:深入介绍了 SPI 的原理、Gamma 分布的数学基础、正态标准化的必要性,以及全球经典工程实例。百分位数方法是 SPI 的一阶近似,完整 SPI 的实现(Gamma 拟合 + 正态标准化)留待 A11 攻关。这种"先建立理论认知,再动手实现"的节奏,符合从入门到进阶的学习规律。

下一步:A6 可在本阶段输出的干旱等级标签基础上,进行干旱等级的时间序列分析、站点空间分布统计,或实现 SPI 的简易版本(Map 端按月汇总 + Reduce 端计算均值/标准差/标准化)。磁盘和内存瓶颈已在 A4 中根治,后续 MR 作业可放心使用标准资源配置。

相关推荐
Volunteer Technology1 小时前
Flink的DataStream分区操作
大数据·linux·flink
米云科技1 小时前
小红书客服软件支持多账号吗?米多客高效解决跨账号管理难题
大数据·人工智能
曾阿伦2 小时前
Elasticsearch Analyzer 分析器开发指南
大数据·elasticsearch·搜索引擎
庞白OS2 小时前
一次ds对话
大数据·人工智能
OCR_133716212752 小时前
技术选型干货:通用大模型与垂直OCR模型算力、成本、资源深度对比
大数据·人工智能
superantwmhsxx2 小时前
GPT-5.5 科研助手实战:从假设提出到实验验证的全流程效果展示
大数据·人工智能·gpt
袋鼠云数栈3 小时前
数栈 V7.0 多模态数据智能平台:打造 AI-Ready 的企业数据底座
大数据·数据结构·数据库·人工智能·数据治理·多模态
风途科技~3 小时前
告别外观辨鸟误区,鸟类性别检测仪实现禽类性别判定
大数据·人工智能
云边云科技_云网融合3 小时前
云边云科技受邀出席 2026 亚马逊云科技中国合作伙伴峰会
大数据·网络·人工智能·科技·云计算