地图泛化这个方向,做过的人都知道痛在哪里。矢量数据不像栅格影像那样规整,一条道路、一栋建筑、一片水域,在数据层面就是一堆带属性的坐标点序列,采样密度不均匀、节点数量差异巨大、几何形态千变万化。传统做法靠人工写规则做化简和选取,规则越堆越多,维护成本高得离谱,换个区域、换个比例尺就得重新调参。这几年大家开始琢磨能不能让模型直接从矢量数据里学出泛化能力,而形状编码就是这条链路里最基础也最容易被低估的一环——编码编不好,后面的分类、选取、化简全是空中楼阁。我前后在这个方向上折腾了不少时间,踩过的坑比走过的路还多,今天就把基于矢量的形状编码与形状分类这套东西掰开揉碎讲清楚,从设计思路到实操细节,再到排查问题的土办法,尽量让刚入门的同学也能照着跑起来,让有经验的同行也能找到几个能直接抄的配置。
1. 整体设计与思路拆解
1.1 为什么矢量形状编码不能照搬图像那一套
很多人第一反应是把矢量图形栅格化成小图片,然后套用成熟的卷积网络。我一开始也这么干过,实测下来问题很明显:栅格化本身就丢信息,分辨率选高了显存吃不消,选低了细长道路直接糊成一团;更要命的是栅格化之后坐标精度没了,原本精确到小数点后六位的节点坐标,变成像素格之后误差被放大,后续做几何化简时根本没法还原。所以矢量形状编码必须直接在点序列上做文章,保留坐标的连续性。
那直接在原始坐标上跑行不行?也不行。原始坐标的绝对值没有意义,同一个形状平移到不同位置,坐标数值完全不同,模型会把它当成两个东西。所以编码的第一步永远是归一化与对齐:把形状平移到质心、缩放到单位尺度、必要时做旋转对齐。这一步看着简单,但归一化的方式直接决定了模型能学到什么。我试过只做平移缩放不做旋转对齐,结果模型对朝向特别敏感,一条南北向的路和东西向的路学出来的特征差异巨大,后来加了主方向对齐才稳住。
另一个关键取舍是变长序列怎么处理。矢量形状的节点数从几个到几千个不等,直接padding到固定长度会引入大量无效填充,注意力机制会被这些填充位稀释。我的做法是设一个上限截断加下采样,超长形状先做等弧长重采样压到固定点数,短形状再padding。等弧长重采样比等参数重采样更合理,因为矢量数据的节点分布往往不均匀,拐角处密、直段处稀,等弧长能保证采样点在几何上是均匀的,不会在直段上浪费采样预算。
1.2 编码器结构选型的权衡
编码器这块我对比过三种方案:序列模型(LSTM/GRU)、一维卷积、Transformer。LSTM对长序列的建模能力有限,几千个点的形状跑起来慢且容易遗忘早期信息;一维卷积感受野有限,要靠堆层数才能覆盖全局,但胜在快、稳定、参数量小;Transformer全局建模能力强,但对数据量要求高,小数据集上容易过拟合。
最后我采用的是卷积加轻量注意力的混合结构:先用几层一维卷积做局部特征提取,把相邻节点的几何关系(转角、边长比、曲率变化)编码进去,再在卷积特征上叠加一层多头注意力做全局聚合。这样既有卷积的局部归纳偏置,又有注意力的全局视野,参数量还控制得住。实测在几万条形状样本上,这个混合结构比纯Transformer收敛快将近一倍,验证集上的分类准确率也高出三个点左右。
提示:如果你的数据集规模在万级以下,建议直接用纯卷积加全局池化,别上注意力,过拟合风险太高,调参调到怀疑人生。
1.3 形状分类任务的目标设定
形状分类不是最终目的,它是为地图泛化服务的中间任务。所以分类的类别体系要跟泛化需求对齐,不能随便按几何形态分。我的类别体系是按地物类型加几何复杂度两个维度交叉划分的:道路按等级分主干、次干、支路,建筑按形态分独立式、联排式、复杂轮廓,水域按形状分带状、面状。这样分的好处是,后续做选取和化简时,模型输出的类别直接对应不同的处理策略,不用再做二次映射。
损失函数上我用的是带类别权重的交叉熵,因为矢量数据里长尾分布极其严重,支路和简单建筑占了绝大多数,复杂轮廓的样本很少。不加权的话模型会偷懒,把所有复杂轮廓都预测成简单类别,整体准确率看着不错,但关键类别全崩。权重按类别频率的倒数开方来设,比直接取倒数温和一些,避免过度补偿导致简单类别欠拟合。
2. 核心细节解析与实操要点
2.1 矢量数据的预处理流水线
预处理是整个流程里最脏最累但最不能省的环节。我整理了一套固定的流水线,按顺序走下来基本能覆盖九成以上的数据问题。
第一步是拓扑清洗。原始矢量数据里常见自相交、悬挂节点、重复点,这些在几何上没意义,但会干扰编码。自相交用简单的线段相交检测加节点插入来修复,悬挂节点按容差合并,重复点直接去重。容差的选择很讲究,太小修不干净,太大把真实细节抹掉了,我一般取数据平均边长的百分之五作为容差基准。
第二步是坐标归一化。先算形状的质心,把所有点平移到以质心为原点,再算所有点到质心的距离,取最大距离做缩放因子,把形状缩放到单位圆内。这里有个细节:缩放因子用最大距离而不是外接矩形对角线,因为外接矩形对细长形状不友好,会把形状压得太扁。
第三步是主方向对齐。用主成分分析算形状的主轴,把主轴旋转到水平方向。对于近似各向同性的形状(比如接近正方形的建筑),主方向不稳定,这时候跳过旋转对齐,只做平移缩放。判断是否各向同性用两个特征值的比值,比值大于零点八就认为接近各向同性。
第四步是等弧长重采样。按累积弧长把形状重采样到固定点数,我一般取一百二十八或二百五十六个点。点数太少拐角信息丢失,太多计算量上去了收益不明显。重采样时保留首尾点的原始坐标,中间点线性插值,这样闭合形状的接缝处不会出现跳变。
import numpy as np def normalize_shape(points, num_samples=128, iso_ratio=0.8): pts = np.asarray(points, dtype=np.float64) # 去重 keep = np.ones(len(pts), dtype=bool) keep[1:] = np.any(np.abs(np.diff(pts, axis=0)) > 1e-9, axis=1) pts = pts[keep] # 平移至质心 centroid = pts.mean(axis=0) pts = pts - centroid # 主方向对齐 cov = np.cov(pts.T) eigvals, eigvecs = np.linalg.eigh(cov) if eigvals[1] / (eigvals[0] + 1e-12) < iso_ratio: pts = pts @ eigvecs # 缩放至单位圆 scale = np.linalg.norm(pts, axis=1).max() pts = pts / (scale + 1e-12) # 等弧长重采样 seg = np.linalg.norm(np.diff(pts, axis=0), axis=1) cum = np.concatenate([[0], np.cumsum(seg)]) total = cum[-1] targets = np.linspace(0, total, num_samples) resampled = np.stack([ np.interp(targets, cum, pts[:, 0]), np.interp(targets, cum, pts[:, 1]) ], axis=1) return resampled这段代码是我实际在用的精简版,去掉了异常处理和日志,核心逻辑都在。注意np.interp对闭合形状的首尾处理,如果形状是闭合的,最好在末尾补一个首点再重采样,否则接缝处会有微小断裂。
2.2 几何特征的手工构造
纯坐标序列喂给网络,模型要自己从坐标里悟出转角、曲率这些几何量,学习效率低。我的做法是手工构造一组几何特征,跟坐标拼接后一起输入。这组特征包括:相邻边的夹角(用向量点积算)、边长与平均边长的比值、局部曲率(用三点外接圆半径的倒数近似)、累积转角。这几个量都是旋转平移不变的,跟归一化后的坐标配合得很好。
累积转角这个特征特别有用,它能刻画形状的整体弯曲趋势。一条蜿蜒的河流和一条笔直的道路,累积转角的曲线形态完全不同,模型很容易区分。计算时注意角度要连续展开,不能直接取绝对值,否则来回弯的形状和一直弯的形状会混淆。
注意:手工特征不是越多越好。我试过加到十几个特征,结果特征之间相关性太高,模型反而学乱了。四到六个互补性强的特征就够了,多了是负担。
2.3 编码器各层的参数配置
卷积部分我用的是四层一维卷积,通道数从三十二逐层翻倍到二百五十六,卷积核大小统一取五,步长为二做下采样。核大小取五是个经验值,太小感受野不够,太大对局部拐角不敏感。每层卷积后接批归一化和ReLU,批归一化对矢量数据特别重要,因为不同区域的坐标尺度差异大,归一化能稳住训练。
注意力部分用两层多头注意力,头数取八,前馈维度取卷积输出通道的两倍。位置编码这里有个坑:标准正弦位置编码假设序列是连续的,但矢量形状重采样后虽然点序连续,几何上的相邻关系却不一定均匀,所以我用的是可学习的位置编码,让模型自己学每个采样位置的重要性。实测比正弦编码在分类任务上高一个多点。
分类头就是全局平均池化接两层全连接,中间加dropout,丢弃率取零点三。池化用平均不用最大,因为最大池化对离群点太敏感,矢量数据里一个异常节点就能把最大池化的结果带偏。
2.4 训练策略与数据增强
矢量形状的数据增强跟图像不一样,不能随便裁剪旋转。我常用的增强有四种:节点抖动(给每个点加小幅度高斯噪声,模拟采集误差)、局部缩放(随机选一段弧长做拉伸或压缩,模拟不同比例尺下的形态变化)、节点丢弃(随机去掉少量节点再重采样,模拟数据简化)、镜像翻转(水平或垂直翻转,注意翻转后要重新做方向对齐)。
节点抖动的幅度要控制好,我取归一化后坐标标准差的百分之二,太大形状就变形了。局部缩放的比例范围取零点八到一点二,超出这个范围形状语义就变了。节点丢弃的比例不超过百分之十,丢多了拓扑都保不住。
优化器用AdamW,学习率初始取千分之一,余弦退火到万分之一,权重衰减取万分之一。批大小取六十四,训练轮数看数据量,一般五十到八十轮收敛。早停的耐心值设十轮,监控指标用验证集上的宏平均F1,不用准确率,原因前面说过,长尾分布下准确率会骗人。
3. 实操过程与核心环节实现
3.1 从原始数据到训练集的完整构建
假设你手上有一批矢量数据,格式可能是GeoJSON或者Shapefile,第一步是把几何部分抽出来转成统一的点序列数组。我写了个小脚本做这件事,核心是把各种几何类型(线、面、多面)统一成点序列,面状要素取外环,多面要素拆成多个独立样本。
import json def extract_sequences(geojson_path): with open(geojson_path, 'r', encoding='utf-8') as f: data = json.load(f) seqs, labels = [], [] for feat in data['features']: geom = feat['geometry'] gtype = geom['type'] if gtype == 'LineString': seqs.append(geom['coordinates']) labels.append(feat['properties'].get('category', 'unknown')) elif gtype == 'Polygon': seqs.append(geom['coordinates'][0]) labels.append(feat['properties'].get('category', 'unknown')) elif gtype == 'MultiPolygon': for poly in geom['coordinates']: seqs.append(poly[0]) labels.append(feat['properties'].get('category', 'unknown')) return seqs, labels抽出来之后逐条过预处理流水线,得到归一化重采样后的数组。这里有个实操细节:预处理要并行化。几万条形状串行处理能跑几个小时,用多进程池按CPU核数并行,速度能压到几分钟。注意每个进程独立处理,别共享状态,否则归一化的中间变量会串。
构建训练集时按类别分层抽样划分训练验证测试,比例取七比一点五比一点五。划分前先统计类别分布,样本数少于五十的类别要么合并要么剔除,否则验证集里可能一个样本都没有,宏平均F1没法算。
3.2 模型搭建与关键代码
模型主体我用的是PyTorch,结构就是前面说的卷积加注意力。这里贴出核心的编码器部分,省略了分类头。
import torch import torch.nn as nn class ShapeEncoder(nn.Module): def __init__(self, in_dim=6, conv_channels=(32, 64, 128, 256), num_heads=8): super().__init__() layers = [] prev = in_dim for ch in conv_channels: layers += [ nn.Conv1d(prev, ch, kernel_size=5, stride=2, padding=2), nn.BatchNorm1d(ch), nn.ReLU(inplace=True) ] prev = ch self.conv = nn.Sequential(*layers) self.pos_embed = nn.Parameter(torch.zeros(1, 64, prev)) encoder_layer = nn.TransformerEncoderLayer( d_model=prev, nhead=num_heads, dim_feedforward=prev * 2, dropout=0.1, batch_first=True ) self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=2) self.pool = nn.AdaptiveAvgPool1d(1) def forward(self, x): # x: (B, N, in_dim) -> (B, in_dim, N) x = x.transpose(1, 2) x = self.conv(x) # (B, C, L) x = x.transpose(1, 2) # (B, L, C) x = x + self.pos_embed[:, :x.size(1), :] x = self.transformer(x) x = x.transpose(1, 2) return self.pool(x).squeeze(-1)输入维度in_dim取六,是归一化坐标两维加四个手工几何特征。卷积下采样后序列长度从一百二十八降到八,位置编码按八来设,代码里写六十四是留了余量,实际用切片截断。注意力层数取二,再多在小数据集上就过拟合了。
提示:
nn.TransformerEncoderLayer的batch_first参数在较老版本里没有,如果你的环境版本低,得手动转置,别在这上面卡半天。
3.3 训练循环与监控指标
训练循环没什么特别的,标准流程。但监控指标我加了几个自定义的:除了损失和宏平均F1,还监控每个类别的召回率,以及混淆矩阵里最容易被混淆的类别对。矢量形状分类里最常见的混淆是支路和次干路、联排建筑和复杂轮廓建筑,盯着这两对的变化能提前发现模型跑偏。
学习率预热我加了五百步的线性预热,从零升到初始学习率,再余弦退火。不加预热的话前几百步损失震荡很厉害,加了之后曲线平滑很多。梯度裁剪阈值取一,矢量数据的梯度偶尔会爆,裁剪一下稳当。
验证集评估时把模型设成eval模式,关掉dropout和批归一化的滑动统计更新。这个细节很多人忘,导致验证指标虚高,实际部署时掉点。
3.4 推理阶段的批处理与后处理
推理时形状长度不一,padding到同一批次里的最大长度就行,但要注意注意力掩码要正确设置,padding位不能参与注意力计算。我一开始忘了加掩码,padding位的特征污染了全局池化结果,分类准确率掉了五个点,查了两天才发现。
后处理主要是把模型输出的类别概率转成最终类别,取argmax就行。但如果下游任务对误判敏感,可以加一个置信度阈值,低于阈值的样本走人工复核或者回退到规则方法。阈值取多少看业务容忍度,我一般取零点六,低于这个值的样本占比大概百分之五到八,人工复核的量可控。
4. 常见问题与排查技巧实录
4.1 训练不收敛的几种典型表现
第一种是损失从头到尾不降,基本是数据预处理出了问题。最常见的是归一化没做对,坐标尺度差异太大,梯度直接爆掉。排查方法很简单:打印一批输入数据的坐标范围,正常应该在负一到一之间,如果出现几百上千的数值,说明归一化漏了或者缩放因子算错了。
第二种是损失降一阵然后卡住,多半是学习率太大或者批归一化统计量不稳。把学习率降一个数量级试试,如果还卡,检查批大小是不是太小,批大小小于十六时批归一化的方差估计噪声很大,建议换组归一化或者加大批大小。
第三种是训练损失降但验证损失升,典型过拟合。先看数据量够不够,几万条以下建议砍掉注意力层,只留卷积。再看增强够不够,节点抖动和局部缩放加上去通常能缓解。还不行就加dropout和权重衰减,但别加太猛,否则欠拟合。
4.2 分类准确率虚高的排查
前面提过长尾分布下准确率会骗人,但还有一种更隐蔽的情况:数据泄漏。如果同一条原始要素被拆成多个样本(比如多面要素拆成多个面),划分数据集时这些样本可能同时出现在训练集和验证集里,模型记住了形状而不是学会了分类。排查方法是按原始要素ID划分,而不是按拆出来的样本划分,确保同一要素的所有子样本落在同一个集合里。
另一个虚高来源是预处理时的信息泄漏。如果归一化的缩放因子用了整个数据集的统计量而不是单条形状自己的,验证集的信息就漏进训练集了。正确做法是每条形状独立归一化,缩放因子只用自己的最大距离。
4.3 形状相似但类别不同的难例处理
矢量数据里有一类难例:几何形态几乎一样,但类别不同。比如一条笔直的路,可能是主干路也可能是支路,光看形状分不出来,得靠属性(宽度、车道数)或者上下文(连接关系)。纯形状编码模型在这类样本上必然出错,这不是模型的问题,是信息不够。
我的处理办法是引入辅助属性通道。把宽度、等级这些标量属性归一化后拼到手工特征里,让模型有额外信息可用。如果属性也没有,那就只能靠上下文,把相邻要素的特征也编码进来,但这会显著增加模型复杂度,建议作为二期优化,一期先把纯形状的基线跑通。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 损失不降 | 归一化错误、坐标尺度异常 | 打印输入坐标范围,检查归一化流水线 |
| 损失震荡 | 学习率过大、缺预热 | 降学习率,加线性预热 |
| 验证指标虚高 | 数据泄漏、归一化用了全局统计 | 按要素ID划分,单条独立归一化 |
| 特定类别全错 | 类别权重不足、样本太少 | 调类别权重,合并或剔除稀有类 |
| 推理掉点 | 忘了eval模式、注意力掩码缺失 | 检查eval调用和padding掩码 |
| 长形状效果差 | 重采样点数不足、截断丢信息 | 提高采样点数,检查截断逻辑 |
| 朝向敏感 | 缺主方向对齐 | 加PCA对齐,各向同性形状跳过 |
4.5 几个踩坑换来的实操心得
第一个心得:预处理代码一定要写单元测试。归一化、重采样这些函数看着简单,边界情况特别多,闭合形状、退化形状(所有点共线)、单点形状,不测的话训练到一半才发现数据有问题,前面几小时白跑。我现在的习惯是每个预处理函数配三到五个测试用例,跑通了再上量。
第二个心得:先跑小样本再上全量。拿几百条样本跑通全流程,确认损失能降、指标能动,再扩到全量。直接上全量的话,一个预处理bug能让你等几个小时才看到报错。
第三个心得:保存中间结果。预处理后的数组、归一化参数、类别映射表都存下来,调试时不用每次重跑预处理。我用的是numpy的npz格式,加载快、体积小。
第四个心得:别迷信复杂结构。我见过不少同行一上来就堆Transformer堆到十几层,结果在小数据集上还不如三层卷积。矢量形状编码这个任务,局部几何信息占主导,卷积的归纳偏置天然合适,注意力是锦上添花不是雪中送炭。先把卷积基线调到最好,再考虑加注意力。
第五个心得:评估要看混淆矩阵不看单一指标。宏平均F1比准确率靠谱,但还不够,得看混淆矩阵里具体哪两类在混。有时候整体指标不错,但某两类互相混淆严重,下游任务里这两类恰恰需要区分,那就得针对性处理,加特征、调权重或者干脆合并类别。
这套流程我在几个不同规模的数据集上都跑过,从几千条到十几万条,核心结构没变,主要是调采样点数和卷积通道数。小数据集上把通道数砍半、注意力层去掉,大数据集上可以适当加深。形状编码这个环节做扎实了,后面的分类、选取、化简才有得谈,否则全是沙上建塔。后续如果要做端到端的泛化,形状编码器可以直接作为骨干网络复用,分类头换成选取或化简的决策头就行,迁移成本很低。