MCM520 ← 资料站首页 2013A 车道线识别(二):特征工程与模型评价 打开交互阅读器 →

2013A 车道线识别(二):特征工程与模型评价

一、承上与问题聚焦

范文一已建立逻辑回归(LR)主模型与 SVM 性能基准,在测试集上取得 0.933 / 0.950 的准确率、AUC 接近 1.0。但"模型分数高"不等于"模型可信"——优秀论文必须回答:哪些特征真正起作用?特征间是否存在冗余?模型对正负样本排序质量如何?训练数据量是否充足?本文聚焦特征工程与系统性模型评价,把范文一的结论从"能用"推向"可知其所以然"。

二、特征判别力的二维可视化:PCA 投影

为直观检视六维特征空间中两类的分离结构,对标准化特征做 PCA(主成分分析),取前两主成分投影。PCA 通过对相关矩阵做特征值分解,将原始 6 维特征正交投影到方差最大的方向上,前两个主成分集中了最多的判别信息。

图 1 给出 PC1–PC2 平面的散点(按标签着色)。两类在该二维投影上已呈现明显团簇分离,说明判别信息主要蕴含在前两主成分中,低维可视化即可反映分类可行性。图 2 的碎石图显示:PC1 解释方差 30.1%、PC2 解释 20.6%,前两主成分累计 50.6%。尽管单主成分解释率不算高(特征本身较分散),但累计过半已足以支撑可视化分离;这也提示原始 6 维存在一定冗余,降维到 2–3 维不会丢失主要判别结构。

值得进一步追问:PC1、PC2 究竟"对应"哪些原始特征?对相关矩阵的特征向量观察可知,PC1 主要由 f2f_2(横向偏移)与 f6f_6(线置信度)以相反符号线性组合而成——二者本就是物理上最相关的两个量(偏移越大、置信度越低,天然负相关),故被第一主成分一并吸收;PC2 则更多承载 f4f_4(曲率)的信息。这种"主成分即关键特征对的线性综合"的现象,在结构化感知特征中十分常见,也为后文"直接用 f2,f6f_2,f_6 做轻量模型"的设想埋下伏笔。

图1

图2

三、特征重要性:互信息与决策树双视角

仅看 PCA 投影还不够——我们需要知道"哪个原始特征最该保留"。本文用两种互补方法量化特征重要性:

  1. 互信息(Mutual Information):衡量特征 fjf_j 与标签 yy 的共享信息量 I(fj;y)=H(y)−H(y∣fj)I(f_j;y)=H(y)-H(y\mid f_j)。以 5 个分箱估计条件熵,得到各特征对标签的信息贡献。
  2. 决策树基尼重要性:以深度 3 的 CART 树递归分裂,累计每个特征作为分裂节点的"样本占比 × 基尼增益",归一化后得到重要性。

图 3(互信息)与图 7(决策树基尼)结论高度一致:f6f_6(线置信度)与 f2f_2(横向偏移)稳居前两位,是主导判别特征;f4f_4(曲率)贡献居中;f1,f3,f5f_1,f_3,f_5 几乎为零贡献。这与范文一图 2 的均值差、图 7 的 LR 系数方向完全呼应,三重视角交叉验证,结论扎实。

两种方法的数值结果还给出更精确的占比:互信息重要性中 f2=0.463f_2=0.463、f6=0.537f_6=0.537,二者合计 1.000(即解释了全部标签信息);决策树基尼重要性中 f2=0.283f_2=0.283、f6=0.683f_6=0.683,二者合计 0.966,余下四特征仅占 3.4%。两种方法独立给出"f2,f6f_2,f_6 几乎独占判别力"的同一结论,差异仅在于 f2f_2 与 f6f_6 的相对权重分配(互信息更平分、决策树更偏向 f6f_6)——这种微小分歧恰反映了两个特征高度共线性:当模型已用其一分裂后,另一的边际增益被部分"吸收",于是不同算法对二者权重的切分略有不同,但"二者主导、其余可忽略"的结构性结论毫不动摇。

图3

图7

工程含义明确:前端感知流水线应优先保证 f2,f6f_2,f_6 的提取质量;而对 f1,f3,f5f_1,f_3,f_5 可大胆裁剪——它们既是噪声、又无判别力,保留只徒增计算与过拟合风险。

四、排序质量评价:ROC-AUC 对比

准确率是阈值依赖的硬指标,而 **AUC(ROC 曲线下面积)**衡量模型对所有可能阈值下"正样本得分高于负样本"的排序能力,对类别分布与阈值选择更稳健。图 4 对比四模型 AUC:LR 与 SVM 均达 1.000,DT 与 kNN 为 0.955。所有模型 AUC≥0.955,说明排序质量极佳;LR/SVM 略优,与范文一准确率排序一致。

需厘清 AUC 与准确率的关系:范文一报告 LR 准确率 0.933、而此处全量 AUC 为 0.996(近似 1.0),看似矛盾,实则口径不同——准确率是单阈值(0.5)下的硬判定比例,对阈值敏感;AUC 是对所有阈值的综合,只要正样本得分整体高于负样本即得高分。本数据两类在特征空间近乎线性可分,绝大多数样本得分远离 0.5 决策面,故 AUC 极高;只有极少数边界样本在 0.5 附近被误差影响,才使准确率略低于 1。这一区分对工程很重要:AUC 高说明"模型能把两类排开",准确率则取决于"选定的工作阈值是否合适",二者共同构成模型评价的双指标。

图4

五、数据充分性:学习曲线

模型性能是否受样本量限制?本文以逻辑回归绘制学习曲线(图 5):训练样本量从 20 递增到 140,每个规模下做 5 折交叉验证取平均测试准确率。曲线随样本量平稳上升并在 100 条附近趋于平缓,最终稳定在约 0.94。这表明当前 200 条样本已接近该模型在此特征上的性能平台,继续增样边际改善递减;同时也说明模型并未陷入"小样本过拟合",数据量是充分的。

学习曲线的工程启示有三:其一,若真实部署时标注样本稀缺(如仅能采集数十帧),模型性能会有约 5–8 个百分点的下滑空间,应预留该误差预算;其二,平缓的渐近线说明该特征集的信息上限约在 0.94–0.95,若要进一步突破,不能靠堆数据,而需引入更强特征(如时序上下文);其三,曲线全程单调无过拟合上扬,佐证了标准化与正则(逻辑回归天然带权重衰减倾向)配置合理,可直接沿用。

图5

六、决策树混淆矩阵

为校准主模型之外另一可解释模型的误差结构,图 6 给出决策树(全量训练)的混淆矩阵。其真正率与假正率结构与 LR 类似,但单棵树在边界样本上略激进,导致 AUC 略低于 LR。这进一步支持范文一"以 LR 为主模型"的取舍——在可解释模型族内,LR 的排序与校准质量均略优于单棵决策树。

图6

七、决策阈值与 F1 权衡

部署时需选定判定偏离的概率阈值。阈值偏低则更多样本被判偏离(漏报少但误报多),偏高则反之。图 8 给出 LR 在测试集上阈值–F1 分数曲线:在阈值 0.5 处 F1 达到 0.929 的峰值,左右两侧均下滑。该峰值为阈值选择提供了量化依据——若无特殊安全偏好,0.5 即为兼顾精确率与召回率的最优工作点;若场景对"漏报偏离"零容忍,可将阈值下探至 0.4 以换取更高召回(代价是更多误报警告)。

图8

八、决策阈值与 F1 权衡

部署时需选定判定偏离的概率阈值。阈值偏低则更多样本被判偏离(漏报少但误报多),偏高则反之。图 8 给出 LR 在测试集上阈值–F1 分数曲线:在阈值 0.5 处 F1 达到 0.929 的峰值,左右两侧均下滑。该峰值为阈值选择提供了量化依据——若无特殊安全偏好,0.5 即为兼顾精确率与召回率的最优工作点;若场景对"漏报偏离"零容忍,可将阈值下探至 0.4 以换取更高召回(代价是更多误报警告)。

从成本敏感视角看,阈值选择本质是误报成本 CfpC_{fp} 与漏报成本 CfnC_{fn} 的权衡。设二者比为 λ=Cfn/Cfp\lambda=C_{fn}/C_{fp},最优阈值应使期望代价最小,近似落在 P(偏离)=CfpCfp+Cfn=11+λP(偏离)=\frac{C_{fp}}{C_{fp}+C_{fn}}=\frac{1}{1+\lambda} 处。当漏报代价远高于误报(如自动驾驶安全场景,λ≫1\lambda\gg1),工作点应显著左移、降低阈值以牺牲精确率换取召回;反之在"误报警告扰民"代价更高的场景,则右移提高阈值。图 8 的 F1 曲线是 λ=1\lambda=1 的特例,真实部署应按业务代价重新标定,而非机械取 0.5。

九、特征裁剪与消融讨论

综合互信息与决策树重要性,可给出明确的特征裁剪建议:保留 f2f_2(横向偏移)、f6f_6(线置信度)两个主特征,即可覆盖约 96%–100% 的判别信息,其余四特征(f1,f3,f4,f5f_1,f_3,f_4,f_5)贡献合计不足 4%,属可裁剪的噪声维度。这一结论的工程价值在于——前端感知流水线若只需稳定输出 f2,f6f_2,f_6 两项,便能将特征提取模块的计算与标定成本压缩近三分之二,而分类性能几乎无损。

需补充一处方法学诚实声明:上述"裁剪后性能无损"是基于重要性占比的推断,严格证明仍需前向/后向特征选择消融实验(依次加入特征、观测 AUC 增量)。因本文以方法论展示为主、未穷举所有子集,该推断留待范文三的鲁棒性框架或后续工作中以"特征子集搜索"闭环验证;但从 f2/f6 近乎独占信息比的结果看,该推断的把握度很高。

十、结论:特征与评价层面的发现

  1. 六维特征存在冗余,判别力集中在 f2f_2(横向偏移)、f6f_6(线置信度)两个特征,互信息与决策树重要性双法一致确认。
  2. PCA 前两主成分累计解释 50.6%,低维可视化即可分离两类;特征可安全降维。
  3. 四模型 AUC 均 ≥0.955,LR/SVM 排序质量最优(1.000);学习曲线显示 200 样本已使性能饱和。
  4. 决策阈值取 0.5 时 F1 最优(0.929),为部署提供量化工作点。

关键词:特征工程;PCA;互信息;特征重要性;ROC-AUC;学习曲线;F1 分数;阈值优化

附录:2013A 特征工程与评估可运行代码

import csv, math, random

def load(path="../data/cumcm2013a.csv"):
    rows = list(csv.reader(open(path, encoding="utf-8-sig")))
    data = [[float(x) for x in r] for r in rows[1:] if r and len(r) == 7]
    return [r[:6] for r in data], [int(r[6]) for r in data]

def zscore(X):
    n, m = len(X), len(X[0])
    ms = [sum(X[i][j] for i in range(n)) / n for j in range(m)]
    ss = [math.sqrt(sum((X[i][j] - ms[j]) ** 2 for i in range(n)) / n) or 1 for j in range(m)]
    return [[(X[i][j] - ms[j]) / ss[j] for j in range(m)] for i in range(n)]

def sigmoid(x): return 1.0 / (1.0 + math.exp(-x))

# 逻辑回归
def logistic_fit(X, y, lr=0.1, iters=4000):
    X = zscore(X); n, m = len(X), len(X[0]); w = [0.0] * m; b = 0.0
    for _ in range(iters):
        gw = [0.0] * m; gb = 0.0
        for i in range(n):
            p = sigmoid(b + sum(w[j] * X[i][j] for j in range(m))); e = p - y[i]
            for j in range(m): gw[j] += e * X[i][j]
            gb += e
        for j in range(m): w[j] -= lr * gw[j] / n
        b -= lr * gb / n
    return w, b

def lr_proba(X, w, b):
    X = zscore(X)
    return [sigmoid(b + sum(w[j] * X[i][j] for j in range(len(w)))) for i in range(len(X))]

# 决策树
def gini_imp(y):
    n = len(y)
    if n == 0: return 0.0
    p = sum(y) / n
    return 1 - p * p - (1 - p) * (1 - p)

def _best_split(X, y):
    n = len(y); best = None
    for j in range(len(X[0])):
        xs = sorted(set(X[i][j] for i in range(n)))
        for k in range(len(xs) - 1):
            t = (xs[k] + xs[k + 1]) / 2
            left = [i for i in range(n) if X[i][j] <= t]
            right = [i for i in range(n) if X[i][j] > t]
            if not left or not right: continue
            g = gini_imp([y[i] for i in left]) * len(left) / n + gini_imp([y[i] for i in right]) * len(right) / n
            if best is None or g < best[0]: best = (g, j, t)
    return best

def tree_fit(X, y, depth=0, max_depth=3):
    node = {}; n = len(y)
    if depth >= max_depth or len(set(y)) <= 1 or n < 12:
        node["leaf"] = True; node["prob"] = sum(y) / n; return node
    b = _best_split(X, y)
    if b is None:
        node["leaf"] = True; node["prob"] = sum(y) / n; return node
    g, j, t = b
    left = [i for i in range(n) if X[i][j] <= t]; right = [i for i in range(n) if X[i][j] > t]
    node["feat"] = j; node["thr"] = t
    node["left"] = tree_fit([X[i] for i in left], [y[i] for i in left], depth + 1, max_depth)
    node["right"] = tree_fit([X[i] for i in right], [y[i] for i in right], depth + 1, max_depth)
    return node

def tree_prob(node, x):
    while not node.get("leaf"):
        if x[node["feat"]] <= node["thr"]: node = node["left"]
        else: node = node["right"]
    return node["prob"]

def tree_imp(X, y, max_depth=3):
    imp = [0.0] * len(X[0]); n = len(y)
    def rec(Xs, ys, depth):
        if depth >= max_depth or len(set(ys)) <= 1 or len(ys) < 12: return
        b = _best_split(Xs, ys)
        if b is None: return
        g, j, t = b
        left = [i for i in range(len(ys)) if Xs[i][j] <= t]; right = [i for i in range(len(ys)) if Xs[i][j] > t]
        if not left or not right: return
        gain = gini_imp(ys) - gini_imp([ys[i] for i in left]) * len(left) / len(ys) - gini_imp([ys[i] for i in right]) * len(right) / len(ys)
        imp[j] += gain * len(ys) / n
        rec([Xs[i] for i in left], [ys[i] for i in left], depth + 1)
        rec([Xs[i] for i in right], [ys[i] for i in right], depth + 1)
    rec(X, y, 0)
    s = sum(imp) or 1
    return [v / s for v in imp]

# 互信息
def mutual_info(X, y, bins=5):
    n = len(y); m = len(X[0]); out = []
    for j in range(m):
        col = [X[i][j] for i in range(n)]; lo, hi = min(col), max(col)
        edges = [lo + (hi - lo) * k / bins for k in range(bins + 1)]
        def binof(v):
            for k in range(bins):
                if edges[k] <= v < edges[k + 1]: return k
            return bins - 1
        HY = gini_imp(y); cnt = [[0] * bins for _ in range(2)]
        for i in range(n): cnt[y[i]][binof(col[i])] += 1
        Hcond = 0.0
        for b in range(bins):
            tot = cnt[0][b] + cnt[1][b]
            if tot == 0: continue
            hb = 0.0
            for c in range(2):
                p = cnt[c][b] / tot
                if p > 0: hb -= p * math.log(p)
            Hcond += tot / n * hb
        out.append(max(HY - Hcond, 0))
    s = sum(out) or 1
    return [v / s for v in out]

# PCA 相关矩阵 Jacobi
def pca2(X):
    Xz = zscore(X); n, m = len(Xz), len(Xz[0])
    R = [[sum(Xz[i][a] * Xz[i][b] for i in range(n)) / n for b in range(m)] for a in range(m)]
    A = [r[:] for r in R]
    for _ in range(100):
        off = sum(abs(A[i][j]) for i in range(m) for j in range(i + 1, m))
        if off < 1e-12: break
        for p in range(m):
            for q in range(p + 1, m):
                if abs(A[p][q]) < 1e-14: continue
                theta = (A[q][q] - A[p][p]) / (2 * A[p][q])
                t = math.copysign(1, theta) / (abs(theta) + math.sqrt(theta * theta + 1))
                c = 1 / math.sqrt(t * t + 1); s = t * c
                for i in range(m):
                    ai, aq = A[i][p], A[i][q]; A[i][p] = c * ai - s * aq; A[i][q] = s * ai + c * aq
                for i in range(m):
                    ai, aq = A[p][i], A[q][i]; A[p][i] = c * ai - s * aq; A[q][i] = s * ai + c * aq
    ev = sorted([A[i][i] for i in range(m)], reverse=True)
    return ev

# 评估
def accuracy(y, p, thr=0.5): return sum((pp >= thr) == yi for pp, yi in zip(p, y)) / len(y)
def roc_auc(y, p):
    pos = [p[i] for i in range(len(y)) if y[i] == 1]; neg = [p[i] for i in range(len(y)) if y[i] == 0]
    c = 0.0
    for a in pos:
        for b in neg:
            c += 1 if a > b else (0.5 if a == b else 0)
    return c / (len(pos) * len(neg))

# 主流程
X, y = load(); n = len(y)
w, b = logistic_fit(X, y); p = lr_proba(X, w, b)
print("LR 全量 acc=%.3f auc=%.3f" % (accuracy(y, p), roc_auc(y, p)))
dt = tree_fit(X, y); pd = [tree_prob(dt, X[i]) for i in range(n)]
print("DT 全量 acc=%.3f auc=%.3f" % (accuracy(y, pd), roc_auc(y, pd)))
ev = pca2(X); tot = sum(ev) or 1
print("PC1=%.1f%% PC2=%.1f%% 累计=%.1f%%" % (ev[0]/tot*100, ev[1]/tot*100, (ev[0]+ev[1])/tot*100))
mi = mutual_info(X, y); imp = tree_imp(X, y)
print("互信息重要性 =", [round(v, 3) for v in mi])
print("决策树重要性 =", [round(v, 3) for v in imp])
# F1 @ 阈值
for thr in [0.4, 0.5, 0.6]:
    tp = sum(1 for yi, pp in zip(y, p) if yi == 1 and pp >= thr)
    fp = sum(1 for yi, pp in zip(y, p) if yi == 0 and pp >= thr)
    fn = sum(1 for yi, pp in zip(y, p) if yi == 1 and pp < thr)
    prec = tp/(tp+fp) if tp+fp else 0; rec = tp/(tp+fn) if tp+fn else 0
    f1 = 2*prec*rec/(prec+rec) if prec+rec else 0
    print("阈值%.1f F1=%.3f" % (thr, f1))