1. 为什么我坚持用 Jupyter Notebook 做数据预处理
先交代一下背景。我日常工作中有一大半时间花在“把乱七八糟的数据弄成能用的数据”——这一步有个专业名字叫数据预处理。以前我习惯用 PyCharm 写一个完整的 .py 文件,跑完看结果,错了一步就得从头看日志,改一处变量又要重新跑一遍全流程。直到我尝试把整个流程搬到 Jupyter Notebook 上,才发现自己以前浪费了多少时间。如果在看这篇文章的你也是个经常和数据打交道的人,我建议你认真看完,因为数据预处理场景下,交互式执行的优势是常态化的刚需,而不是锦上添花。
Jupyter Notebook 的核心价值可以概括为三个词:分段执行、状态保留、可视化反馈。分段执行意味着我可以把“读数据—清洗—转换—统计—导出”拆成一个一个的单元格,跑到哪里断了就在哪里修,不用每次从头跑。状态保留则解决了“内存里的东西不丢”的问题:你上一步读入的 DataFrame、计算好的掩膜、调整过的参数,全部存在内核里,后续单元格能直接引用。可视化反馈更是杀手锏,一个matplotlib出图放在那里,数据长什么样一目了然,比对着 10 万行的表格看头部几行要直观得多。
这套工作流不仅适合表格数据,对栅格数据、遥感影像同样适用。我接下来会用一个 NPP 夜间灯光数据的例子,完整走一遍数据预处理的各个环节,把安装配置、清洗逻辑、裁剪投影、统计导出、踩坑排查都串起来。你可以把它当成一份直接抄的作业,也可以在此基础上替换成你自己的数据。
1.1 为什么交互式执行对预处理这么重要
说白了,数据预处理是个“试错”的过程。你拿到一份数据,不知道它的缺失值长什么样、单位是什么、坐标系有没有偏离,只能一个个条件去试。脚本语言里一次完整的python script.py执行,通常要等几十秒甚至几分钟才能看到结果,中间一个字段名写错,又要重来。Notebook 则把整个过程变得像“聊天”一样,写一个单元格、跑一个单元格,立刻能看到输出。
我现在的习惯是:第一轮先建一个“侦察型 Notebook”,专门用来摸数据的底,比如 df.info()、df.describe()、df.head(),然后是栅格数据的 crs、分辨率、nodata、波段数。这些探索性的判断通常会产生很多临时变量,如果在脚本里,最后要么删掉要么注释掉,非常繁琐;在 Notebook 里就无所谓,留着也不碍事。第二轮再根据侦察结果,写正式的清洗流程。这种“先侦察、后动刀”的思路,能减少大量无效代码。
还有一个实验室场景的好处,就是可以保留实验的“中间状态”。我在处理夜间灯光数据的时候,重投影、裁剪这类栅格操作非常耗时,经常跑到一半我要去确认某个参数对不对。在 Notebook 里我可以随时暂停,改掉参数,然后只重跑当前单元格和后续部分,前面的数据读入、预处理都不用重新执行。
1.2 环境搭建的两种姿势与选择逻辑
聊环境,总有人问到底是装 Anaconda 还是 Miniconda,还是直接用 pip 一个个装。我的看法是:如果你不是已经有 Python 基础的老手,直接上 Anaconda 最省心。因为 Anaconda 自带 Python 解释器、Jupyter Notebook 和一大堆常用库,装完就能用;而 Miniconda 只是最小化环境管理器,还得手动创建环境和安装包。不要被很多教程里“Anaconda 太笨重”的说法吓到,这年头磁盘空间没这么金贵,把时间花在安装配置上才是真的不划算。
如果一定要用命令行,我的推荐是这么一条:
# 创建独立环境,尽量指定 Python 版本,避免日后依赖冲突 conda create -n geodata python=3.9 -y conda activate geodata # 安装核心数据分析库 conda install -y jupyter pandas numpy matplotlib # 处理栅格、矢量数据的常用库 conda install -y rasterio geopandas rioxarray这里有个细节:我明确要求python=3.9,而不是直接用默认的 Python 3.12 之类。原因很简单,rasterio、GDAL 这类底层库的编译依赖非常重,虽然官方维护者一直在跟新版本,但偶尔还是会遇到某个轮子没跟上最新版 Python 的情况。用 3.9 或者 3.10,在 conda-forge 上几乎能找到所有你需要的预编译包,少很多麻烦。
如果你是 pip 用户,也有一条对应的路径:
pip install notebook pandas numpy matplotlib pip install rasterio geopandas rioxarray不过我个人的经验是,GDAL 相关的生态库用 conda 安装的稳定性明显更好。因为 conda 不仅装 Python 包,还会统一管理 GDAL、PROJ、GEOS 这些底层 C 库的版本。rasterio 这类库对底层库版本极度敏感,pip 安装经常会遇到一个包依赖新版 GDAL、另一个包锁定旧版 GDAL 的冲突。conda 在这方面像个大管家,会把所有底层依赖一起协调好。
1.3 先装顺手的三件套:目录、默认路径和快捷键
很多人用 Jupyter Notebook,都是打开浏览器,新建一个 ipynb,然后开始写。但用了一段时间你就会发现,不解决三个问题,Notebook 用起来会很难受:一是文件多了之后目录乱成一团;二是不知道文件默认保存到哪里,来回切换烦人;三是重复操作全靠鼠标,效率很低。
第一件事,是给 Notebook 添加目录。老版本的 Jupyter Notebook(比如 6.x)可以通过插件实现,命令是:
conda install -c conda-forge jupyter_contrib_nbextensions -y jupyter nbextension enable --py jupyter_contrib_nbextensions --sys-prefix装完重启 Jupyter,打开任意一个 ipynb,上方菜单栏如果有 Nbextensions 标签,进去勾选 Table of Contents (2),页面上就会出现侧边目录。但注意,如果你用的是 JupyterLab(现在很多新版本的默认界面就是 Lab),那么其实不用装任何插件,它自带 Table of Contents 面板,点左侧那个带层叠图标的面板就能看到当前 notebook 的目录结构。所以如果你在旧版 Notebook 里折腾了半天插件不生效,我建议直接切换到 JupyterLab 试试。
第二件事,修改默认保存路径。默认情况下,Jupyter Notebook 会把文件保存在启动时所在目录,如果你每次都是从不同的地方启动,文件就会散落各处。我自己的做法是,把所有 ipynb 统一放在D:/jupyter_workspace下面,操作方式分两步:
# 先生成配置文件 jupyter notebook --generate-config然后用文本编辑器打开生成的文件(一般在用户目录下的.jupyter/jupyter_notebook_config.py),找到这一行:
# c.NotebookApp.notebook_dir = ''改成:
c.NotebookApp.notebook_dir = 'D:/jupyter_workspace'保存之后,再启动 Jupyter Notebook,你会发现默认工作目录已经变了。这个配置也适用于 JupyterLab,配置文件里改成c.ServerApp.notebook_dir也可以,看你使用的版本。还有一个小技巧:如果你只是想临时指定某个目录,不需要改配置,可以用jupyter notebook --notebook-dir=D:/jupyter_workspace这种方式。
第三件事,是几个高频快捷键。在 Notebook 里,按一次Esc进入命令模式,然后:
B:在当前单元格下方新建一个单元格A:在当前单元格上方新建一个单元格D D:连续按两次 D 删除当前单元格M:把当前单元格切换为 MarkdownY:把当前单元格切换为代码Shift + Enter:运行当前单元格并跳到下一个
这组快捷键里我最常用的是Shift + Enter和M。写数据预处理的文档时,我会频繁插入 Markdown 分隔说明,用快捷键切换模式比鼠标点击快得多。我的个人心得是:数据预处理 Notebook 里,Markdown 单元格的注释几乎和代码一样重要,你一周后再回头看,还能回忆起每一步在处理什么。
2. 数据预处理不是写代码,是先画流程图
很多人处理数据的习惯是,拿到数据直接开始写 pandas 操作,写一步看一步,最后数据处理完了,整个流程却说不清楚。这种做法在小数据集上还行,一旦数据量上来、步骤变多,就很容易出现“处理完发现某个条件判断错了、前面所有清洗都白做”的悲剧。
我现在的习惯是,动手前先在 Notebook 顶部用 Markdown 画一个流程草图,哪怕是几个列表也够用。这一步表面上看耽误了五分钟,实际上能帮你节省至少半小时的返工时间。数据预处理说到底是一条流水线:原始数据进来,经过清洗、变换、合并、采样、校验,最终变成可以进入建模或分析的规范数据集。流水线的每一道工序,都要想清楚它要解决什么问题。
2.1 一次完整预处理的六个步骤
如果把数据预处理分成标准步骤,大致是这六段:
- 数据加载与侦察:读入数据,了解字段类型、缺失值、分布、坐标系、分辨率等基本属性。
- 数据清洗:处理缺失值、重复值、异常值、非法值,让数据在“物理层面”变得规范。
- 数据变换:重命名、类型转换、归一化、维度调整、重投影、重采样等,让数据在“结构层面”变得可用。
- 数据整合:多张表拼接、栅格与矢量叠加、时间序列对齐等。
- 数据统计与样本梳理:生成描述性统计,抽样核对数据合理性。
- 数据导出与文档:输出规范文件,记录处理参数、版本号和步骤索引。
这个框架不只是我的习惯,几乎所有成熟的数据团队都有类似的分层。比如说你导出的最终数据出了问题,你要能够回溯到它是从第几步开始坏的。如果中间产物都留着,排查就很简单;如果全都混在一个变量里覆盖来覆盖去,就只能从头再来。
2.2 把“中间产物规范”定下来
我强烈建议在开始预处理之前,先定义一个“产物规范”。这里指的是一套固定的命名、格式和组织规则,比如:
- 原始数据单独放在
raw/目录,永远不修改;修改后的数据放在processed/。 - 中间产物文件名带上步骤号,比如
01_读入检查.pkl、02_清洗后.csv、03_裁剪.tif、04_重采样.tif。 - 统一用
YYYYMMDD作为日期后缀,方便按版本回滚。 - 统计结果和清洗后的数据分开存放,不要混在一个文件里。
这套规范看起来很简单,实际操作中却能救你很多次。举个例子,我在处理夜间灯光数据时,因为中间要经过裁剪、重投影两次栅格运算,每步生成的 tif 都很大,运行时间也不短。如果我把每一步结果保存成一个中间 tif,就算后面的操作出了问题,我也只需要从对应的中间结果重新开始,而不是把前面的运行又跑一遍。数据预处理有个铁律:凡是跑得慢或不好恢复的步骤,都值得做中间落盘。
2.3 为什么选 NPP 夜间灯光数据做演示案例
我做技术选型的时候,喜欢挑一个“能覆盖绝大多数问题”的案例数据。NPP 夜间灯光数据恰好就是这么个典型。它是一种栅格影像数据,常被用于城市扩展分析、区域社会发展研究等场景,优点是数据相对容易获取、覆盖范围广、结构清晰;缺点也很明显:坐标系需要严格处理、原始数据有大量边界异常值、空间分辨率多种多样、文件体积不小。
它的技术难点集中在四个方向:一是栅格影像的读写不是寻常的 txt、csv,需要 rasterio 这类专业库;二是坐标系投影需要特殊处理,用错投影直接导致空间偏移;三是负值、极值、NoData 这类“脏数据”在影像里比表格里更隐蔽,必须通过可视化才能发现;四是栅格的统计方式与表格不同,需要掩膜、分区统计等专业操作。任何一个玩过遥感或地理数据分析的人,都会被这四个问题打磨过一遍。
如果你只想快速体验流程,可以用任何一张 GeoTIFF 栅格数据替代 NPP 夜间灯光数据,步骤完全通用。但如果你想把整个案例完整复现,那就跟着我的流程走,你会发现这远比单纯处理一个 csv 学到的东西多得多。
3. 完整实操:NPP 夜间灯光数据预处理,从 tif 到统计表
下面进入正题。我用一台普通 Windows 笔记本,环境是 conda 创建的geodata,核心库是rasterio、geopandas、rioxarray。整个案例的目标是:把原始 NPP 夜间灯光 GeoTIFF 文件读取、清洗、裁剪、重投影,最终输出一个研究区内的像元统计表,并生成一张可读的成果图。
这里要做一个说明:NPP-VIIRS 夜间灯光数据是 NASA/NOAA 合成的月度平均夜间灯光强度产品,单位一般为nW/cm2/sr,文件格式是 GeoTIFF,数据类型有浮点型和整型两种版本。它每个像元的值代表该位置的平均夜间灯光辐亮度,值越大通常说明该区域夜间灯光越强。它不像普通 CSV 那样可以直接在 Excel 里打开检查,必须借助专业工具。
3.1 侦察数据:读进来先把“底细”摸清
我拿到一份栅格数据,从来不会急着写统计代码,而是先做一个“读入检查”。这段代码信息量非常大:
import rasterio import numpy as np import matplotlib.pyplot as plt from rasterio.plot import show # 数据路径,自行替换成你自己的文件 tif_path = 'data/raw/VNL_v2_npp_202203_avg_vcmslcfg.tif' with rasterio.open(tif_path) as src: print('CRS:', src.crs) print('尺寸(宽,高):', src.width, src.height) print('分辨率:', src.res) print('波段数:', src.count) print('NoData:', src.nodata) print('数据范围:', src.bounds) # 读取第一个波段 band1 = src.read(1) print('数据类型:', band1.dtype) print('像元值范围:', np.nanmin(band1), np.nanmax(band1)) # 快速看一眼直方图 plt.figure(figsize=(10, 4)) plt.hist(band1[band1 > 0].ravel(), bins=50, range=(0, 200)) plt.title('夜间灯光像元值直方图(仅正值)') plt.show()这段代码的输出会告诉我几件事:坐标系是什么,很多时候 NPP 数据用的是 WGS 84 经纬度坐标;分辨率是多少,常见的是 15 弧秒,大概相当于赤道附近 500 米;数据类型的浮点还是整型;NoData 用的什么标记。这些信息直接决定了后面的处理参数。
很多人在这一步就开始犯错了。有些人直接src.read(1)之后拿着数据就统计,结果把 NoData 当成了真实 0 值,或者把坐标投影忽略掉,直接把经纬度当成平面坐标来算面积和距离。我建议把这一步的打印结果保存下来,作为后续处理文档的“数据档案”。
3.2 清洗:处理负值、NoData 与极值
NPP 夜间灯光数据的“脏”,主要体现在负值和极端值上。月度合成的 VNL 数据虽然经过了初步处理,但依然可能存在少量负值像元,这些负值并不是真实灯光,而多半是定标、几何校正过程中的噪声产物。我的清洗原则是:负值一律先置为零或 NoData,不要直接当有效值参与统计。
import numpy as np # 重新读取 with rasterio.open(tif_path) as src: band = src.read(1) profile = src.profile # 查看负值像元数 neg_mask = band < 0 print('负值像元个数:', np.sum(neg_mask)) print('负值占比: {:.6f}%'.format(np.sum(neg_mask) / band.size * 100)) # 清洗:负值置为 NoData 或 0 # 这里选择置为0,相对于负值更有利于后续统计 band[neg_mask] = 0 # 防止极值干扰,做一个上限截断 extreme_mask = band > 200 print('超过200的像元个数:', np.sum(extreme_mask)) # 如果确认是异常值,可以截断,否则保留 # band[extreme_mask] = 200这个上限截断需要谨慎使用。夜间灯光数据在某些高亮度区域(比如大型机场、商业中心)确实会有很高的值,直接截断反而会损失信息。我这里的extreme_mask只是先看一眼数量,并不代表一定要处理。实际操作中我会结合研究区域的实际情况判断:如果是做全局统计分析,且异常像元占比极小,我会选择保留;如果异常像元挤占直方图导致可视化严重失真,我会考虑对数变换或截断。处理异常值没有统一答案,核心原则是:每一次清洗操作,都要记录下来操作原因和参数。
NoData 的处理相对标准一些。如果用rioxarray,可以这样:
import rioxarray raster = rioxarray.open_rasterio(tif_path, masked=True) print('NoData 处理后的数据维度:', raster.shape) # masked=True 会把 NoData 位置变成 NaN print('NaN 像元数:', int(raster.isnull().sum()))采用masked=True的好处是,后续的统计函数可以用nansum、nanmean这类函数直接跳过无效像元,不用自己手写掩膜。这一点在处理有大量海域或云覆盖区域的影像时特别重要。
3.3 按研究区裁剪并统一坐标系
接下来是数据预处理的大头:裁剪。也就是只保留你关心区域的像元。如果研究区是一个行政区或一个自定义矩形边界,裁剪有两种常见方式:按矢量边界裁剪和按经纬度范围裁剪。我以按矢量边界裁剪为例,这也是遥感领域最常用的方式。
import geopandas as gpd import rasterio from rasterio.mask import mask as rio_mask # 读取研究区矢量边界,这里用一份简化面数据 study_area = gpd.read_file('data/vector/study_area.shp') # 确保矢量与栅格在同一坐标系 # 如果矢量是 WGS84 经纬度,而栅格也是 WGS84,则跳过 reproject # 如果不是,请用 study_area = study_area.to_crs(栅格crs) crs_raster = rasterio.open(tif_path).crs study_area = study_area.to_crs(crs_raster) # 按边界裁剪 with rasterio.open(tif_path) as src: out_image, out_transform = rio_mask(src, study_area.geometry, crop=True, nodata=0) out_meta = src.meta.copy() print('裁剪后尺寸:', out_image.shape) # 更新元数据 out_meta.update({ "driver": "GTiff", "height": out_image.shape[1], "width": out_image.shape[2], "transform": out_transform, "nodata": 0 }) # 保存中间结果 with rasterio.open('data/processed/01_裁剪_研究区.tif', "w", **out_meta) as dest: dest.write(out_image)这段逻辑里有几个容易踩的坑。第一,裁剪之前必须检查矢量与栅格的坐标系是否一致,不一致要to_crs转换。第二,rio_mask的crop=True表示对边界进行最小矩形裁剪,这样输出影像会比原始影像小很多,既能提升后续处理速度,也能避免不必要的内存占用。第三,nodata=0这个参数要和前面清洗策略保持一致,如果前面把负值置为 0,这里裁掉边界外的部分也用 0 填充,整条流水线才一致。
如果你只需要一个矩形范围,也可以用经纬度范围裁剪,代码要简单很多:
# 用经纬度范围裁剪 min_lon, min_lat = 110.0, 30.0 max_lon, max_lat = 112.0, 32.0 if raster.rio.crs is None or raster.rio.crs.to_epsg() != 4326: raster = raster.rio.reproject("EPSG:4326") clipped = raster.rio.clip_box(minx=min_lon, miny=min_lat, maxx=max_lon, maxy=max_lat)我这里用的rioxarray,它封装了 rasterio,很多操作一行就能搞定,代码可读性也更好。你如果习惯纯 rasterio,那就按第一个示例来,两个思路是一样的。
3.4 重采样与统计:从栅格到表格
先同步一个概念:重采样就是把不同分辨率的栅格统一到一个目标分辨率。为什么要做这步?因为很多时候你要拿夜间灯光数据和别的数据做叠加分析,比如人口格网、土地利用影像,它们的分辨率可能是 100 米、250 米、1000 米,跟 NPP 的 500 米左右不一样。不统一分辨率,像元之间无法对齐,统计就无从谈起。
import rioxarray # 重采样到目标分辨率(例如500米) target_resolution = 500 clipped = rioxarray.open_rasterio('data/processed/01_裁剪_研究区.tif', masked=True) # 计算目标网格尺寸 # 先用一个简单的估算方法,得到新分辨率下的行列数 width = int((clipped.rio.bounds()[2] - clipped.rio.bounds()[0]) / target_resolution) height = int((clipped.rio.bounds()[3] - clipped.rio.bounds()[1]) / target_resolution) # 重采样,使用平均值方法,对夜间灯光这种连续型变量更合适 resampled = clipped.rio.reproject( clipped.rio.crs, resolution=target_resolution, resampling=rioxarray.enums.Resampling.average ) print('重采样后 shape:', resampled.shape) # 保存重采样结果 resampled.rio.to_raster('data/processed/02_重采样_500m.tif')关于采样算法,我简单说一下选择逻辑:对连续型数值变量(比如夜间灯光辐亮度、气温、降水),优先用average(平均值重采样),因为它不会丢失总辐射信息;对分类变量(比如土地利用类型),优先用nearest(最邻近)或mode(众数),因为分类值不能被平均出“疑似新类别”;如果是要精确配准、不希望改变像元值的范围,可以用bilinear(双线性插值)。不要看到一个库函数就用默认参数,要搞清楚它的默认行为是什么,不然数据上的偏差会很晦涩。
重采样之后,就可以分组统计了。假如你想统计每个县级行政区的夜间灯光总量、均值、最大值,最直接的方案是用分区统计(zonal statistics)。这里我手动实现一份,是希望你理解背后的逻辑,后续哪怕换成超大数据集,也心里有数。
import geopandas as gpd import pandas as pd import numpy as np # 读取矢量边界(保持与裁剪前一致的投影坐标系) polygons = gpd.read_file('data/vector/study_area.shp') # 转换到与栅格数据相同的投影 polygons = polygons.to_crs(resampled.rio.crs) # 用于提取像元中心坐标 from rasterio.features import geometry_mask # 这里简化:先把栅格数据转成数组坐标 data = resampled.values[0] # 单波段 xmin, ymin, xmax, ymax = resampled.rio.bounds() width = resampled.rio.width height = resampled.rio.height # 像元大小 pixel_size_x = (xmax - xmin) / width pixel_size_y = (ymax - ymin) / height # 生成像元中心坐标网格 xs = np.arange(xmin + pixel_size_x/2, xmax, pixel_size_x) ys = np.arange(ymax - pixel_size_y/2, ymin, -pixel_size_y) X, Y = np.meshgrid(xs, ys)上面这段代码生成了像元中心坐标。接下来的思路是:对每个矢量多边形,用空间判断找出哪些像元落在里面。这里有很多现成库可以用,比如rasterstats的zonal_stats,但如果你数据量不大,自己写也能练练手。更推荐的是直接装rasterstats:
conda install -c conda-forge rasterstats -y用法非常简洁:
from rasterstats import zonal_stats stats = zonal_stats( 'data/vector/study_area.shp', 'data/processed/02_重采样_500m.tif', stats=['sum', 'mean', 'max', 'min', 'count'], nodata=0 ) # 转为 DataFrame 并和行政区名称合并 df_stats = pd.DataFrame(stats) df_study = gpd.read_file('data/vector/study_area.shp')[['name', 'geometry']] df_result = pd.concat([df_study.drop(columns='geometry'), df_stats], axis=1) print(df_result.head()) # 导出成 csv df_result.to_csv('data/processed/03_区域灯光统计.csv', index=False, encoding='utf-8-sig')nodata=0这里又一次出现,是为了让统计函数跳过 NoData 像元。encoding='utf-8-sig'这个参数是我特意加的,用 Excel 打开中文 CSV 时,如果不用 utf-8-sig,会直接乱码。老读者知道我有多少次倒在中文编码这个坑上,这个参数可以说是国产数据从业者的必备细节。
3.5 校验:出图对比与数值抽查
数据预处理做完,不等于万事大吉。我始终觉得置信度比什么参数都重要,所以一定要做校验。第一层校验是可视化:把清洗、裁剪前后的影像画出来对比,人工肉眼检查有没有明显错位或者空洞。
fig, axes = plt.subplots(1, 3, figsize=(15, 5)) # 原始数据 with rasterio.open(tif_path) as src: original = src.read(1, masked=True) axes[0].imshow(original, cmap='cividis') axes[0].set_title('原始 NPP 夜间灯光') # 裁剪重采样后的数据 resampled_vis = resampled.values[0] axes[1].imshow(resampled_vis, cmap='cividis') axes[1].set_title('裁剪+重采样后') # 热力图式展示 axes[2].imshow(resampled_vis, cmap='magma') axes[2].set_title('假彩色展示') plt.tight_layout() plt.show()如果影像边界与研究区矢量对不上,比如整体偏移了几个像元,那一定是投影或者裁剪步骤出了问题,需要回头检查。
第二层校验是数值抽查。从最终的统计表里挑出典型区域,手动计算几个像元的均值,看看和统计表是否一致。也可以在控制台里打印一些关键位置的像元值,跟统计结果做交叉验证。对于一个汇总统计结果,我还习惯检查三个环节:总和是否为正、是否存在明显超界值、count 列的有效像元数是否合理。这些指标如果异常,我就能在建模之前拦住错误,而不是把脏数据送进下游流程。
4. 踩过的坑与排查技巧实录
最后这部分,我把这些年实打实踩过的坑按主题列出来。这些坑并不高深,但每一个都曾经浪费过我小半天时间。我把它们整理成一份“排查手册”,希望你能绕开。
4.1 坑一:中文标签乱码,图和表全废
matplotlib 默认字体不支持中文,这是老生常谈。但具体到 Notebook 里,还有个隐蔽问题:Notebook 输出的图在网页里能显示,导出成图片却常常乱码。解决办法是,在 Notebook 最开头就执行一次全局配置:
import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei', 'Arial Unicode MS'] plt.rcParams['axes.unicode_minus'] = Falseaxes.unicode_minus = False这行很多人会漏。它解决的是负号显示成方块的问题,尤其在做数据预处理时经常遇到负值统计、差异图之类的图,设置不设置差别非常大。另外,如果上面这几个字体都没有,也可以直接用plt.rcParams['font.family'] = 'sans-serif'并指定你系统里已经安装的中文字体名字,比如 Linux 下有Noto Sans CJK SC。
4.2 坑二:Notebook 跑着跑着内存爆了
栅格数据看起来不大,可一旦读到内存里,尤其是多次读取、反复重采样之后,很容易把 16G 内存占满。我有一次处理 30 米分辨率的影像,原始文件才 800M,裁剪重采样之后反而占了几 GB 内存,Notebook 直接卡死。后来我总结出三条原则。
第一条,能用rasterio窗口读就按窗口读,不要一张图整个载入。比如统计时可以用windowed reading分块处理。第二条,每次跑完大计算,手动清理一下大变量:del resampled_vis然后调用gc.collect(),不要指望内存自动释放。第三条,中间结果及时落盘,内存里只保留当前步骤必要的数据。尤其是你在 Notebook 里反复点击“重新运行”,旧变量不会自动消失,积少成多就会爆内存。建议每次修改代码前,先Restart Kernel and Run All,确保从干净的内核状态开始跑。
4.3 坑三:pandas 的 SettingWithCopyWarning
处理表格数据时,最常见的告警就是这个SettingWithCopyWarning。它的本质是:你从一个 DataFrame 切片出子集,然后对子集赋值,但 pandas 无法判断你到底是改了原始 DataFrame 还是只改了一个副本。这个告警看着不致命,但它经常意味着后面统计里的数据根本没有被真的改掉。
我现在的习惯是:凡是需要写回的子集,一律用.copy()显式复制:
subset = df[df['region'] == '华东'].copy() subset['normalized'] = subset['night_light'] / subset['night_light'].max()不要为了省那点内存省略.copy()。数据预处理阶段,安全性永远优先于性能。还有一种变体是链式索引df[df['a']==1]['b'] = 0,这种写法我基本不用,因为它不仅会触发告警,而且赋值行为在不同 pandas 版本下都可能不同。
4.4 坑四:GDAL/Rasterio 环境冲突
这个前面提过一些,这里展开讲。如果你用 pip 安装 rasterio,大概率会遇到底层 GDAL 的链接问题,最常见的报错是ERROR 4: Unable to open ...或者OSError: Can't load GDAL library。根本原因是系统里有多个 GDAL 版本,rasterio 加载了错误的那一个。
我的经验是,在 Windows 上,用 conda 创建环境后,rasterio、fiona、pyproj、geopandas 统一用 conda 安装,不要混着 pip。如果你确实需要用 pip 装一些 conda 里没有的包,装完再conda install --force-reinstall rasterio来修复底层库的链接关系。在 Linux 服务器上,尽量避免自己从源码编译 GDAL,直接conda install -c conda-forge gdal rasterio是最稳妥的。
还有一种常见情况是rio.to_raster()在保存 tif 时提示Non-Georeferenced dataset。这种通常是因为内存中的数组已经丢掉了坐标系元数据,或者你在操作时误把rio.write_crs()覆盖成 None。解决办法是在保存前重新声明:
resampled.rio.write_crs(resampled.coords['spatial_ref'].attrs['crs_wkt'], inplace=True) resampled.rio.to_raster('output.tif')4.5 坑五:小细节但致命的类型问题
这一条不算坑,更像血泪经验总结。在数据预处理时,pandas 的列经常读出来是 object 类型,看着是数字却不能参与运算,需要先pd.to_numeric转换。日期时间列也可能被读成字符串,导致排序错乱。如果你是在做栅格和矢量的连接分析,还要注意字段名是否匹配、空间索引是否建立。很多时候,预处理代码本身没问题,问题出在读进来的字段类型和预期不一致。我每次进入下一步操作前,常用一行代码做检查:
print(df.dtypes)养成这类习惯之后,你会发现自己写代码的速度并没有变快,但是返工率会明显下降。
4.6 常见问题速查表
为了看着方便,我把高频问题整理成一张速查表,放在这里供你直接参照。
| 问题现象 | 常见原因 | 首选排查路径 |
|---|---|---|
| Notebook 默认目录不对 | 配置文件未生效或配置文件路径错误 | 运行jupyter notebook --generate-config后,检查配置路径 |
| 中文出现方块或乱码 | matplotlib 字体配置缺失 | 设置plt.rcParams['font.sans-serif']和axes.unicode_minus |
| 栅格读取报错无法打开 | GDAL 库冲突或路径错误 | 确认路径存在;用 conda 统一安装 gdal、rasterio |
| 统计结果偏差极大 | NoData 被当成有效值参与运算 | 检查nodata与masked设置,确保统计时跳过无效像元 |
| 裁剪出空白影像 | 矢量与栅格坐标系不一致 | 输出前打印两边的 crs,进行to_crs |
| CSV 中文 Excel 打开乱码 | 文件编码不是 UTF-8 with BOM | 导出时用encoding='utf-8-sig' |
| 内存溢出被内核杀 | 大变量未释放、全图载入 | 删除大变量并gc.collect(),用分块读取或小范围裁剪 |
5. 未完:把自己变成流水线的一部分
如果你能坚持读完前面这些内容,说明你已经做好和“脏数据”长期共存的准备了。我个人的体会是,数据预处理真正深奥的地方,不是某个库的某个函数怎么写,而是如何在每一步操作里保留可回溯的证据,让你和任何一个后来接手的人,都能说清楚“这份数据为什么被处理成了这样”。这也是我为什么把每次预处理的流程固定成步骤、固定成命名、固定成校验动作。技巧会过时,库的 API 会更新,但这种对过程透明性的坚持,放在任何一个数据项目里都不会错。
最后再分享一个小技巧:每次开始一项新的预处理任务时,我会在 Notebook 第一格写上项目名称、数据来源、创建日期、处理人和一句“处理目标”,然后才碰任何代码。这些信息会随 Notebook 一起被保存、分享,哪怕三个月后有人问起这个表格是怎么来的,也能直接翻出答案一清二楚。很多时候,数据工作者最重要的产出物,不是模型精度的提升,而是让处理过程经得起追问。希望这篇经验之谈,对你有用。