2024年4月

论文提出能够适配硬件加速的动态网络DS-Net,通过提出的double-headed动态门控来实现动态路由。基于论文提出的高性能网络设计和IEB、SGS训练策略,仅用1/2-1/4的计算量就能达到静态SOTA网络性能,实际加速也有1.62倍

来源:晓飞的算法工程笔记 公众号

论文: Dynamic Slimmable Network

Introduction


模型速度在模型的移动端应用中十分重要,提高模型推理速度的方法有模型剪枝、权值量化、知识蒸馏、模型设计以及动态推理等。其中,动态推理根据输入调整其结构,降低整体计算耗时,包含动态深度和动态维度两个方向。如图2所示,动态网络自动在准确率和计算量之间trade-off,比静态的模型设计和剪枝方法要灵活。

然而,论文发现包含动态维度的网络的实际运行速度大都不符合预期,主要原因在于动态剪枝后的稀疏卷积与当前硬件的计算加速不匹配。大多数卷积核的动态剪枝通过zero masking(常规卷积后再通过mask取对应的输出)或path indexing(直接通过
\([:,:]\)
获取对应的新卷积再计算)来实现,如表1所示,这些方法的计算效率都不高,导致整体推理速度没有加快。
为了解决这一问题,论文提出了动态可精简网络DS-Net,在实现动态网络的同时也有很好的硬件匹配性。
论文的主要贡献如下:

  • 提出新的动态网络路由机制,通过提出的double-headed动态门控来实现网络结构的动态路由。另外,卷积的动态剪枝通过切片的方式保持权值的内存连续性,可以很好地适配硬件加速。
  • 提出用于DS-Net的两阶段训练方式,包含IEB和SGS方法。IEB用于稳定可精简网络的训练,SGS用于提高门控输出的多样性,两者都能帮助提高DS-Net的性能。
  • 通过ImageNet实验对比,DS-Net的整体性能比SOTA动态网络高约5.9%,比ResNet和MobileNet等静态网络性能稍微下降,但是有2-4倍计算量节省以及1.62倍实际推理加速。

Dynamic Slimmable Network


论文提出的dynamic slimmable network通过学习可精简的超网(supernet)以及动态门控(gating)机制,达到根据不同输入样本动态生成网络的目的。如图3所示,DS-Net的超网为包含全部完整卷积的完整网络。动态门控则是一系列预测模块,根据输入动态设定每个阶段的卷积维度,进而生成子网,这一过程也称为动态路由(dynamic routing)。
目前的动态网络研究中,主网络和动态路由通常是联合训练的,类似于联合优化的网络搜索方法。参考one-shot NAS方法,论文提出解耦的两阶段训练方法来保证DS-Net中每个路径的泛化性。在stage I中,禁用门控的功能并用IEB方法训练超网,在stage II中,固定超网的权值单独用SGS方法训练门控。

Dynamic Supernet

这里先介绍可在硬件高效运行的通道切片方法以及论文设计超网,然后再介绍Stage I中用到的IEB方法。

  • Supernet and Dynamic Channel Slicing

在如动态裁剪、动态卷积等动态网络中,卷积核
\(\mathcal{W}\)
根据输入
\(\mathcal{X}\)
进行动态参数化
\(\mathcal{A}(\theta, \mathcal{X})\)
,这样的卷积可表示为:

动态卷积根据输入去掉不重要的特征通道,降低理论计算量,但其实际加速大都不符合预期。由于通道的稀疏性与硬件加速技术不匹配,在计算时不得不多次索引和拷贝需要的权值到新的连续内存空间再进行矩阵相乘。为了更好地加速,卷积核在动态权值选择时必须保持连续且相对静态。
基于上面的分析,论文设计了结构路由器
\(\mathcal{A}(\theta)\)
,能够偏向于输出稠密的选择结果。对于
\(N\)
输出、
\(M\)
输入的卷积核
\(W\in\mathbb{R}^{N\times M}\)
,结构路由器输出精简比例
\(\rho\in(0,1]\)
,通过切片操作
\([:]\)
选择卷积核的前
\(\rho\times N\)
部分构成切片动态卷积:

\([:]\)
切片操作加
\(*\)
稠密矩阵乘法要比索引操作或稀疏矩阵相乘要高效得多,保证了实际运行时的速度。

  • SuperNet

将多个动态卷积组合起来即可搭建超网,超网通过设置不同的特征维度组合创建多个子网。将结构路由器禁用时,超网等同于常见可精简网络,可用类似的方法进行预训练。

  • In-place Ensemble Bootstrapping

经典的Universally Slimmable Networks通过两个方法来有效地提升整体的性能:

  • sandwich rule:每次训练的网络组合包含最大的子网、最小的子网以及其它子网,其中最大的子网和最小的子网分别决定了可精简网络性能的上界和下界。
  • in-plcae distillation:将最大子网的向量输出作为其它子网的训练目标,而最大子网的训练目标则是数据集标签,这样对可精简网络更好地收敛有很好的帮助。

虽然in-place distillation很有效,但最大子网权值的剧烈抖动会导致训练难以收敛。根据BigNas的实验,使用in-place distillation训练较为复杂的网络会极其不稳定。如果没有残差连接或特殊的权值初始化,在训练初期甚至会出现梯度爆炸的情况。为了解决可精简网络收敛难的问题并且提升整体性能,论文提出了In-plcae Ensemble Boostrapping(IEB)方法。
首先,参考BYOL等自监督和半监督方法,使用过往的表达能力进行自监督的in-plcae distillation训练的做法,将模型的指数滑动平均(EMA, exponential moving average)作为目标网络生成目标向量。定义
\(\theta\)

\(\theta^{'}\)
为在线网络和目标网络:

\(\alpha\)
为动量因子,控制历史参数的比例,
\(t\)
为训练轮次。在训练时,模型的EMA会比在线网络更加稳定和准确,为精简子网提供高质量的训练目标。
接着,参考MealV2使用一组teacher网络来生成更多样的输出向量供student网络学习的做法,在进行in-place distillation时使用不同的子网构成一组teacher网络,主要提供目标向量给最小子网学习。

整体训练过程如图4所示。结合sandwich rule和上述优化的in-place distillation,每论训练有以下3种网络:

  • 最大的子网
    \(L\)
    使用数据集标签作为训练目标。
  • \(n\)
    个随机维度的子网使用目标网络的最大子网的向量输出作为训练目标。
  • 最小的子网使用上述子网在目标网络中对应的子网的向量输出的组合作为训练目标,即训练目标为:

总结起来,超网训练的IEB损失为:

Dynamic Slimming Gate

这里先介绍公式2中输出
\(\rho\)
因子的结构路由器
\(\mathcal{A}(\theta, \mathcal{X})\)
以及动态门控的double-headed设计,最后再介绍Stage II训练使用的sandwich gate sparsification(SGS)方法。

  • Double-headed Design

将特征图转换为精简比例
\(\rho\)
有两种方法:1)标量模式:直接通过sigmoid输出0到1的标量作为精简比例。2)one-hot模式:通过argmax/softmax得到one-hot向量,选择离散的候选向量
\(L_p\)
中对应的精简比例。
论文对这两种方法进行对比后,选择了性能更好的one-hot模式。为了将特征图
\(\mathcal{X}\)
转换为one-hot向量,将
\(\mathcal{A(\theta, \mathcal{X})}\)
转换为两个函数的组合:

\(\mathcal{E}\)
将特征图下采样为向量,
\(\mathcal{F}\)
将向量转化为one-hot向量用于后续的维度切片。参考DenseNet等网络,
\(\mathcal{E}\)
为全局池化层,
\(\mathcal{F}\)
为全连接层
\(W_1\in\mathbb{R}^{d\times C_n}\)
+ReLU+
\(W_2\in\mathbb{R}^{g\times d}\)
+argmax函数(
\(d\)
为中间特征维度,
\(g\)

\(L_p\)
的长度):

以图3的第
\(n\)
个门控为例,将大小为
\(\rho_{n-1}C_n\times H_n\times W_n\)
的特征图
\(\mathcal{X}\)
转换成向量
\(\mathcal{X}_{\mathcal{E}}\in \mathbb{R}^{\rho_{n-1}C_n}\)
,随后用argmax将向量进一步转换成one-hot向量,最后通过计算one-hot向量与
\(L_p\)
的点积得到预测的精简比例:

论文采用的精简比例生成方法跟通道注意力方法十分类似,通过添加第三个全连接层
\(W_3^{\rho_{n-1}\times d}\)
,可直接为网络引入注意力机制。基于上面的结构,论文提出double-headed dynamic gate,包含用于通道路由的hard channel slimming head以及用于通道注意力的soft channel attention head,其中soft channel attention head定义为:

\(\delta(x)=1+tanh(x)\)
,channel attention head参与stage I的训练。

  • Sandwich Gate Sparsification

在stage II训练中,论文使用分类交叉熵损失
\(L_{cls}\)
和复杂度惩罚函数
\(L_{cplx}\)
来端到端地训练门控,引导门控为每个输入图片选择最高效的子网。为了能够用
\(L_{cls}\)
来训练不可微的slimming head,论文尝试了经典的gumbel-softmax方法,但在实验中发现门控很容易收敛到静态的选项,即使加了Gumbel噪声也优化不了。
为了解决收敛问题并且增加门控的多样性,论文提出Sandwich Gate Sparsification(SGS)训练方法,使用最大子网和最小子网识别输入图片中的hard和easy,为其生成slimming head输出精简因子的GT。基于训练好的超网,将输入大致地分为三个级别:

  • Easy samples
    \(\mathcal{X}_{easy}\)
    :能够被最小子网识别的输入。
  • Hard samples
    \(\mathcal{X}_{hard}\)
    :不能被最大子网识别的输入。
  • Dependent samples
    \(\mathcal{X}_{dep}\)
    :不属于上述两种的输入。

为了最小化计算消耗,easy samples应该都使用最小子网进行识别,即门控的GT为
\(\mathcal{T}(\mathcal{X}_{easy})=[1,0,\cdots,0]\)
。而对于dependent samples和hard samples则应该鼓励其尽量使用最大的子网进行识别,即门控的GT为
\(\mathcal{T}(\mathcal{X}_{hard})=\mathcal{T}(\mathcal{X}_{dep})=[0,0,\cdots,1]\)
。基于这些生成的门控GT,SGS损失定义为:

\(\mathbb{T}_{sim}(\mathcal{X})\in{0,1}\)
代表
\(\mathcal{X}\)
是否应该被最小子网预测,
\(\mathcal{L}_{CE}(\mathcal{X},\mathcal{T})=-\sum\mathcal{T}*log(\mathcal{X})\)
为门控输出与生成GT之间交叉熵损失。

Experiment


与不同类型的网络对比ImageNet性能。

CIFAR-10性能对比。

VOC检测性能对比。

对IEB训练方法各模块进行对比实验。

对比SGS损失与精简比例分布的可视化。

对比不同的SGS训练策略,Try Best为本文的策略,Give up为放弃hard samples,将其归类为最小精简网络的目标。

对比不同门控设计细节。

Conclusion


论文提出能够适配硬件加速的动态网络DS-Net,通过提出的double-headed动态门控来实现动态路由。基于论文提出的高性能网络设计和IEB、SGS训练策略,仅用1/2-1/4的计算量就能达到静态SOTA网络性能,实际加速也有1.62倍。



如果本文对你有帮助,麻烦点个赞或在看呗~
更多内容请关注 微信公众号【晓飞的算法工程笔记】

work-life balance.

在构建用户界面时,控件扮演着至关重要的角色。它们不仅负责展示内容,还处理用户的交互。然而,有时标准的控件库可能无法满足我们的需求,这时自绘控件就显得尤为重要。在Avalonia UI框架中,自绘控件允许我们完全掌控控件的渲染逻辑,实现高度自定义的UI元素。本文将深入探讨自绘控件的概念、优势、应用场景,并通过示例代码展示如何创建自绘控件以及自定义事件。

什么是自绘控件?

自绘控件,顾名思义,是指需要开发者自行绘制和渲染的控件。与传统的由框架负责渲染的控件不同,自绘控件的渲染逻辑完全由开发者掌控。这意味着开发者可以利用Avalonia提供的绘图API,在控件的绘制上下文中绘制任何想要的形状、图像或文字,从而创造出独特且个性化的UI元素。

自绘控件的优势是什么?

自绘控件具有诸多优势,使其在很多场景下成为理想的选择:

  1. 高度自定义:自绘控件允许开发者根据需求定制控件的外观和行为,打破了框架内置控件的限制。

  2. 性能优化:对于需要频繁绘制或更新UI的场景,自绘控件可以通过优化绘制逻辑来提高性能。

  3. 跨平台一致性:由于自绘控件的渲染逻辑完全由开发者控制,因此可以确保在不同操作系统和平台上具有一致的外观和行为。

  4. 集成第三方图形库:自绘控件可以方便地集成第三方图形库,从而扩展控件的功能和效果。

自绘控件的应用场景

自绘控件在多种场景下都能发挥巨大作用:

  • 自定义图表和图形:如绘制特殊的图表、自定义的进度条、温度计等图形界面。
  • 游戏和动画:需要高性能图形渲染的游戏或动画应用,自绘控件可以提供更灵活和高效的绘制能力。
  • 特殊效果:如自定义的鼠标悬停效果、过渡动画等。
  • 专业工具:如CAD绘图软件、图像处理软件等,这些工具通常需要高度自定义的UI元素来支持复杂的操作。

示例代码:创建自绘控件并自定义事件

下面是一个简单的示例,展示了如何在Avalonia中创建一个自绘控件,并在其中自定义一个事件。

首先,我们定义一个自绘控件CustomControl,并重写其Render方法来绘制UI:

CustomControl.cs

usingAvalonia.Controls;usingAvalonia.Input;usingAvalonia.Interactivity;usingAvalonia.Media;usingAvalonia;usingSystem;namespaceAvaloniaApplication1
{
public classCustomControl : Control
{
//自定义事件 public static readonly RoutedEvent<RoutedEventArgs> CustomClickEvent =RoutedEvent.Register<CustomControl, RoutedEventArgs>("CustomClick", RoutingStrategies.Bubble);public event EventHandler<RoutedEventArgs>ClickTriggered
{
add
=>AddHandler(CustomClickEvent, value);
remove
=>RemoveHandler(CustomClickEvent, value);
}
//触发自定义事件的方法 protected virtual voidOnCustomClick(RoutedEventArgs e)
{
RaiseEvent(e);
}
public override voidRender(DrawingContext context)
{
base.Render(context);//在这里绘制UI,例如绘制一个矩形 var bounds = this.Bounds;var brush = newSolidColorBrush(Colors.LightBlue);var pen = new Pen(Brushes.Black, 1);
context.DrawRectangle(brush, pen,
newRect(bounds.Size));
}
//假设我们想在点击控件时触发自定义事件 protected override voidOnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);//当点击事件发生时,触发自定义的Click事件 OnCustomClick(newRoutedEventArgs(CustomClickEvent));
}
}
}

接下来,我们在XAML中使用这个自绘控件,并为其自定义事件添加处理程序:

MainWindow.axaml

<Windowxmlns="https://github.com/avaloniaui"xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"xmlns:vm="using:AvaloniaApplication1.ViewModels"xmlns:d="http://schemas.microsoft.com/expression/blend/2008"xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"xmlns:local="clr-namespace:AvaloniaApplication1"mc:Ignorable="d"d:DesignWidth="800"d:DesignHeight="450"x:Class="AvaloniaApplication1.Views.MainWindow"x:DataType="vm:MainWindowViewModel"Icon="/Assets/avalonia-logo.ico"Title="AvaloniaApplication1">

    <Design.DataContext>
        <vm:MainWindowViewModel/>
    </Design.DataContext>

    <local:CustomControlClickTriggered="CustomControl_OnCustomClick"/>
</Window>

最后,在C#代码中实现事件处理程序:

MainWindow.axaml.cs

private void CustomControl_OnCustomClick(objectsender, RoutedEventArgs e)
{
//在这里处理自定义点击事件 Debug.WriteLine("Custom click event triggered!");
}

在上面的代码中,我们定义了一个名为CustomControl的自绘控件,它重写了Render方法来自定义绘制逻辑,并在点击时触发自定义的CustomClick事件。

然后,在XAML中我们使用了这个控件,并为其CustomClick事件指定了一个处理程序CustomControl_OnCustomClick。

最后,在C#代码中实现了这个处理程序,当事件被触发时,会打印“Custom click event triggered!”。

通过这个示例,我们可以看到自绘控件在Avalonia中的强大之处。它们不仅允许我们完全掌控控件的外观和行为,还能通过自定义事件实现复杂的交互逻辑。在实际开发中,我们可以根据具体需求创建各种独特的自绘控件,从而为用户带来更加丰富和个性化的体验。

4 月 1 日,Infinity宣布端到端 RAG 解决方案 RAGFlow 开源,仅一天收获上千颗星,到底有何魅力? 我们来安装体验并从代码层面来分析看看。

安装体验

服务器需要有docker,或者直接访问官方提供的demo:
https://demo.ragflow.io/

docker-compose安装

  • 需要确保
    vm.max_map_count
    不小于 262144 【
    更多
    】:
sysctl -w vm.max_map_count=262144  
  • 克隆仓库:
   $ git clone https://github.com/infiniflow/ragflow.git   
  • 进入
    docker
    文件夹,利用提前编译好的 Docker 镜像启动服务器:
   $ cd ragflow/docker   $ docker compose -f docker-compose-CN.yml up -d  

核心镜像文件大约 15 GB,可能需要一定时间拉取。请耐心等待。

体验

启动成功后,浏览器输入
http://服务器ip
或者直接访问官方demo
https://demo.ragflow.io/

注册登录,进入后可以创建知识库,然后上传文档。

image.png

上传成功后,可以通过解析状态查看解析进度,也可以配置文档的parser解析方法,以更好的解析内容。

点击文档名称,可以进入文档详情,查看拆分的chunk,可以看到普通的文本是按照token拆分,还未实现按照段落语义拆分,差评。表格是单独抽取出来,独立存储的,将文档里的表格比较好的还原为了html表格,准确率尚可,这里好评。每个chunk有原文截图,点击后,右边的pdf预览,可以高亮当前的chunk所在区域,翻了下代码,使用的
react-pdf-highlighter
,体验挺好的一个组件。

image.png

DeepDoc 介绍

DeepDoc 是 RAGFlow 的核心组件,它利用视觉信息和解析技术,对文档进行深度理解,提取文本、表格和图像等信息。DeepDoc 的功能模块包括:

  • OCR, 支持将图片、PDF识别为文本。
    image.png

  • 版面识别,识别文档的标题、段落、表格、图像等。

image.png

  • 表格结构识别 (TSR),识别的行、列,以及合并的单元格。
    image.png

  • 支持多类型文档解析,比如PDF、DOCX、EXCEL 和 PPT,甚至图片 ,并提取文本块、表格和图像等信息。

DeepDoc CV模型

DeepDoc的模型应该是基于paddleOCR的模型去微调训练的,开源出来的模型是onnx格式的。

OCR识别

主要代码在ocr.py里,代码定义
TextRecognizer
做文字识别,
TextDetector
做文本框检测,OCR整合检测和识别功能,对外提供调用。

OCR的核心流程:

  • 创建 OCR 实例,load模型
  • 调用
    __call__
    方法,传入图像数据。
    • 使用 TextDetector 进行文本检测,获取文本框坐标
    • 对每个文本框,使用 get_rotate_crop_image 方法进行旋转和裁剪
    • 使用 TextRecognizer 对裁剪后的图像进行文本识别
    • 过滤掉置信度低于阈值(0.5)的识别结果。
  • 返回最终的文本框坐标和识别结果。

版面分析

版面分析主要在
recognizer.py

layout_recognizer.py
里,定义了一个名为
LayoutRecognizer
继承
Recognizer
的类,用于对文档图像进行板式分析,识别不同类型的区域,例如表格、标题、段落等。这里用的模型应该还是基于paddleocr里的版面分析模型去优化的。

先看
Recognizer

__call__
方法,传入图像列表和置信度阈值:

def __call__(self, image_list, thr=0.7, batch_size=16):  
    res = []  
    imgs = []  
    for i in range(len(image_list)):  
        if not isinstance(image_list[i], np.ndarray):  
            imgs.append(np.array(image_list[i]))  
        else: imgs.append(image_list[i])  
  
    batch_loop_cnt = math.ceil(float(len(imgs)) / batch_size)  
    for i in range(batch_loop_cnt):  
        start_index = i * batch_size  
        end_index = min((i + 1) * batch_size, len(imgs))  
        batch_image_list = imgs[start_index:end_index]  
        inputs = self.preprocess(batch_image_list)  
        print("preprocess")  
        for ins in inputs:  
            bb = self.postprocess(self.ort_sess.run(None, {k:v for k,v in ins.items() if k in self.input_names})[0], ins, thr)  
            res.append(bb)  
  
    #seeit.save_results(image_list, res, self.label_list, threshold=thr)  
  
    return res
  • 先预处理,将图像列表转换为模型输入格式
  • 然后调用ort_sess执行onnx推理,最后postprocess,提取模型返回的布局信息,包括区域类型、坐标和置信度。

再看
LayoutRecognizer

__call__
方法,这里是模型应用的工程代码部分,很多细节的小技巧,先上代码,里面加了一些注释:

def __call__(self, image_list, ocr_res, scale_factor=3,  
             thr=0.2, batch_size=16, drop=True):  
    # 可以过滤的垃圾数据  
    def __is_garbage(b):  
        patt = [r"^•+$", r"(版权归©|免责条款|地址[::])", r"\.{3,}", "^[0-9]{1,2} / ?[0-9]{1,2}$",  
                r"^[0-9]{1,2} of [0-9]{1,2}$", "^http://[^ ]{12,}",  
                "(资料|数据)来源[::]", "[0-9a-z._-]+@[a-z0-9-]+\\.[a-z]{2,3}",  
                "\\(cid *: *[0-9]+ *\\)"  
                ]  
        return any([re.search(p, b["text"]) for p in patt])  
    # 调用父类的模型识别  
    layouts = super().__call__(image_list, thr, batch_size)  
    # save_results(image_list, layouts, self.labels, output_dir='output/', threshold=0.7)  
    assert len(image_list) == len(ocr_res)  
    # Tag layout type  
    boxes = []  
    assert len(image_list) == len(layouts)  
    garbages = {}  
    page_layout = []  
    for pn, lts in enumerate(layouts):  
        # OCR 识别的box文本框  
        bxs = ocr_res[pn]  
        # layout转换为box形式  
        lts = [{"type": b["type"],  
                "score": float(b["score"]),  
                "x0": b["bbox"][0] / scale_factor, "x1": b["bbox"][2] / scale_factor,  
                "top": b["bbox"][1] / scale_factor, "bottom": b["bbox"][-1] / scale_factor,  
                "page_number": pn,  
                } for b in lts]  
        # 按照(top,x0)排序  
        lts = self.sort_Y_firstly(lts, np.mean(  
            [l["bottom"] - l["top"] for l in lts]) / 2)  
        # 清理重叠的layout  
        lts = self.layouts_cleanup(bxs, lts)  
        page_layout.append(lts)  
  
        # Tag layout type, layouts are ready  
        # 这里其实是为文本框box分配layout,find不是特别准确  
        def findLayout(ty):  
            nonlocal bxs, lts, self  
            # 找对应了下的layout type  
            lts_ = [lt for lt in lts if lt["type"] == ty]  
            i = 0  
            # 为 ocr detect的box标记 layout_type            while i < len(bxs):  
                # 已标记,跳过  
                if bxs[i].get("layout_type"):  
                    i += 1  
                    continue  
                # 垃圾信息,删除掉  
                if __is_garbage(bxs[i]):  
                    bxs.pop(i)  
                    continue  
  
                # 寻找与box重叠的layout  
                ii = self.find_overlapped_with_threashold(bxs[i], lts_, thr=0.4)  
                # 未找到  
                if ii is None:  # belong to nothing  
                    bxs[i]["layout_type"] = ""  
                    i += 1  
                    continue  
                lts_[ii]["visited"] = True  
                # 保留特征,为header或者footer,且在内容区域的边界内(这里定义了0.1,0.9)  
                keep_feats = [  
                    lts_[ii]["type"] == "footer" and bxs[i]["bottom"] < image_list[pn].size[1] * 0.9 / scale_factor,  
                    lts_[ii]["type"] == "header" and bxs[i]["top"] > image_list[pn].size[1] * 0.1 / scale_factor,  
                ]  
                # 满足丢弃条件,删除box,文本放入garbages  
                if drop and lts_[ii]["type"] in self.garbage_layouts and not any(keep_feats):  
                    if lts_[ii]["type"] not in garbages:  
                        garbages[lts_[ii]["type"]] = []  
                    garbages[lts_[ii]["type"]].append(bxs[i]["text"])  
                    bxs.pop(i)  
                    continue  
  
                # 符合要求的box,分配layout  
                bxs[i]["layoutno"] = f"{ty}-{ii}"  
                bxs[i]["layout_type"] = lts_[ii]["type"] if lts_[  
                    ii]["type"] != "equation" else "figure"  
                i += 1  
  
        # 遍历layout类型,为文本框分配layout,之所以分开,是因为一个文本框可能和多个layout重叠,这里是减少冲突  
        for lt in ["footer", "header", "reference", "figure caption",  
                   "table caption", "title", "table", "text", "figure", "equation"]:  
            findLayout(lt)  
  
        # add box to figure layouts which has not text box  
        # 将没有文本框的figure添加到boxes中,并更新ocr_res  
        for i, lt in enumerate(  
                [lt for lt in lts if lt["type"] in ["figure", "equation"]]):  
            # 有文本框重叠的图片,visited已经设置过  
            if lt.get("visited"):  
                continue  
            lt = deepcopy(lt)  
            del lt["type"]  
            lt["text"] = ""  
            lt["layout_type"] = "figure"  
            lt["layoutno"] = f"figure-{i}"  
            bxs.append(lt)  
  
        boxes.extend(bxs)  
  
    # 更新ocr_res  
    ocr_res = boxes  
  
    garbag_set = set()  
    for k in garbages.keys():  
        garbages[k] = Counter(garbages[k])  
        for g, c in garbages[k].items():  
            if c > 1:  
                garbag_set.add(g)  
  
    ocr_res = [b for b in ocr_res if b["text"].strip() not in garbag_set]  
    return ocr_res, page_layout

大概解释下:

  • __call__
    方法,它接收以下参数:image_list(图像列表),ocr_res(OCR识别的文本框),scale_factor(缩放因子,默认值为3),thr(阈值,默认值为0.2),batch_size(批处理大小,默认值为16),drop(是否删除,默认值为True)
  • 首先调用父类的call方法,将图片交给PP Structure模型识别出layouts,并清理重叠的layout(layouts_cleanup)
  • 然后就是为文本框box分配layout,根据layout type,从layout里找出对应type的layout,如果和box有重叠大于阈值,就为box分配layout,不满足条件的box会被丢弃,比如包含垃圾文本(
    __is_garbage
  • 接着对于没有文本框的figure、equation 添加到boxes中,并更新ocr_res
  • 最后返回更新后的ocr_res,以及page_layout信息

DeepDoc 的parser功能

上面的
OCR

版面分析
,都是为
parser
服务的,
parser
负责解析文档,并拆分为
chunk
.

框架提供了PdfParser、PlainParser、DocxParser、ExcelParser、PptParser 5种解析器。

from .pdf_parser import HuParser as PdfParser, PlainParser  
from .docx_parser import HuDocxParser as DocxParser  
from .excel_parser import HuExcelParser as ExcelParser  
from .ppt_parser import HuPptParser as PptParser

另外针对
resume
,提供了专门的简历解析功能。

我们挑选重点的
PdfParser
也就是
HuParser
来分析。

PdfParser

首先,初始化:

def __init__(self):  
    self.ocr = OCR()  
    if hasattr(self, "model_speciess"):  
        self.layouter = LayoutRecognizer("layout." + self.model_speciess)  
    else:  
        self.layouter = LayoutRecognizer("layout")  
    self.tbl_det = TableStructureRecognizer()  
    self.updown_cnt_mdl = xgb.Booster()

加载了上面说的
OCR

LayoutRecognizer
,以及
TableStructureRecognizer
用于表格结构识别,updown_cnt_mdl,一个xgb模型用来合并box。全靠模型很能做到满意的效果,所以一般都是模型搭配大量的工程trick,靠一些规则来解决一些边界情况。文档解析也是这样,需要多个模型配合,结合一些规则来做,这些规则通常是经验的集合,大白话就是各种case跑出来,遇到问题就加新的规则,都是泪。

不发散,我们来看
PdfParser
核心的
__call__
:

def __call__(self, fnm, need_image=True, zoomin=3, return_html=False):  
    # 转图片,处理文本,ocr识别  
    self.__images__(fnm, zoomin)  
    # 版面分析  
    self._layouts_rec(zoomin)  
    # table box 处理  
    self._table_transformer_job(zoomin)  
    # 合并文本块  
    self._text_merge()  
    self._concat_downward()  
    # 过滤分页信息  
    self._filter_forpages()  
    # 表格和图表抽取  
    tbls = self._extract_table_figure(  
        need_image, zoomin, return_html, False)  
    # 抽取的文本(去掉表格), 表格  
    return self.__filterout_scraps(deepcopy(self.boxes), zoomin), tbls
  • 首先
    __images__
    实现pdf转图片,读取pdf里的文本,并用ocr识别文本块等
  • 然后进行版面识别
  • 将识别到的table做处理
  • 合并文本块
  • _concat_downward
    使用
    updown_cnt_mdl
    模型来做合并
  • _filter_forpages
    过滤pdf里的分页信息
  • _extract_table_figure
    抽取页面里的表格和图片,表格会转换为html
  • __filterout_scraps
    合并文本块(去掉表格后的)
  • 最后返回合并后的文本和表格

这里的每一步都较为复杂,我们挑重点的来说。

pdf转图片

这里代码较多,大概几件事情,分开来讲:

pdf文件读取

def __images__(self, fnm, zoomin=3, page_from=0,  
               page_to=299, callback=None):  
    self.lefted_chars = []  
    self.mean_height = []  
    self.mean_width = []  
    self.boxes = []  
    self.garbages = {}  
    self.page_cum_height = [0]  
    self.page_layout = []  
    self.page_from = page_from  
    try:  
        self.pdf = pdfplumber.open(fnm) if isinstance(  
            fnm, str) else pdfplumber.open(BytesIO(fnm))  
        self.page_images = [p.to_image(resolution=72 * zoomin).annotated for i, p in  
                            enumerate(self.pdf.pages[page_from:page_to])]  
        self.page_chars = [[c for c in page.chars if self._has_color(c)] for page in  
                           self.pdf.pages[page_from:page_to]]  
        self.total_page = len(self.pdf.pages)  
    except Exception as e:  
        self.pdf = fitz.open(fnm) if isinstance(  
            fnm, str) else fitz.open(  
            stream=fnm, filetype="pdf")  
        self.page_images = []  
        self.page_chars = []  
        mat = fitz.Matrix(zoomin, zoomin)  
        self.total_page = len(self.pdf)  
        for i, page in enumerate(self.pdf):  
            if i < page_from:  
                continue  
            if i >= page_to:  
                break  
            pix = page.get_pixmap(matrix=mat)  
            img = Image.frombytes("RGB", [pix.width, pix.height],  
                                  pix.samples)  
            self.page_images.append(img)  
            self.page_chars.append([])
  • 首先初始化一些变量,如lefted_chars、mean_height、mean_width、boxes、garbages等。
  • 然后,首先尝试使用
    pdfplumber
    库打开PDF文件,并获取指定范围页面的文本和图像,
    pdfplumber
    是一个出名的python解析pdf的库,可以较好的提取文本、矩形、图片等,可以返回每个char字符的坐标、大小等信息。
  • 如果发生异常,将尝试使用fitz库作为替代方案,fitz的话就读取不到文本了,会当成图像来处理。

pdf目录读取

这里使用了PyPDF2库来读取pdf的目录信息,但是貌似是基本的读取,其实pdf的目录可以关联到具体的章节内容,这里暂时看起来没有很好的利用。

self.outlines = []  
try:  
    self.pdf = pdf2_read(fnm if isinstance(fnm, str) else BytesIO(fnm))  
    outlines = self.pdf.outline  
  
    def dfs(arr, depth):  
        for a in arr:  
            if isinstance(a, dict):  
                self.outlines.append((a["/Title"], depth))  
                continue  
            dfs(a, depth + 1)  
    dfs(outlines, 0)  
except Exception as e:  
    logging.warning(f"Outlines exception: {e}")  
if not self.outlines:  
    logging.warning(f"Miss outlines")

然后是英文文档检测,大概就是利用正则匹配。

logging.info("Images converted.")  
self.is_english = [re.search(r"[a-zA-Z0-9,/¸;:'\[\]\(\)!@#$%^&*\"?<>._-]{30,}", "".join(  
    random.choices([c["text"] for c in self.page_chars[i]], k=min(100, len(self.page_chars[i]))))) for i in  
    range(len(self.page_chars))]  
if sum([1 if e else 0 for e in self.is_english]) > len(  
        self.page_images) / 2:  
    self.is_english = True  
else:  
    self.is_english = False

分页处理

这里是核心的代码:

  • 会对文本做处理,适当的添加空格
  • 对每页进行
    __ocr
    处理
  • 更新解析进度
  
for i, img in enumerate(self.page_images):  
    chars = self.page_chars[i] if not self.is_english else []  
    # 计算字符的平均宽度、高度  
    self.mean_height.append(  
        np.median(sorted([c["height"] for c in chars])) if chars else 0  
    )  
    self.mean_width.append(  
        np.median(sorted([c["width"] for c in chars])) if chars else 8  
    )  
    self.page_cum_height.append(img.size[1] / zoomin)  
    j = 0  
    while j + 1 < len(chars):  
        # 对满足条件的添加空格(只包含数字、字母、逗号、句号、冒号、分号、感叹号和百分号, 两个字符宽度小于width的一半  
        if chars[j]["text"] and chars[j + 1]["text"] \  
                and re.match(r"[0-9a-zA-Z,.:;!%]+", chars[j]["text"] + chars[j + 1]["text"]) \  
                and chars[j + 1]["x0"] - chars[j]["x1"] >= min(chars[j + 1]["width"],  
                                                               chars[j]["width"]) / 2:  
            chars[j]["text"] += " "  
        j += 1  
    # if i > 0:  
    #     if not chars:    #         self.page_cum_height.append(img.size[1] / zoomin)    #     else:    #         self.page_cum_height.append(    #             np.max([c["bottom"] for c in chars]))    # OCR 识别  
    self.__ocr(i + 1, img, chars, zoomin)  
    if callback:  
        callback(prog=(i + 1) * 0.6 / len(self.page_images), msg="")

callback方法会更新文档解析进度,在文档页面可以查看实时进度

__ocr
处理

虽然叫OCR,但是主要做的是detect,检测文本框,然后根据经验规则来对文本块做处理:

def __ocr(self, pagenum, img, chars, ZM=3):  
    # 检测文本框  
    bxs = self.ocr.detect(np.array(img))  
    if not bxs:  
        self.boxes.append([])  
        return  
    bxs = [(line[0], line[1][0]) for line in bxs]  
    # 按照Y轴坐标排序  
    bxs = Recognizer.sort_Y_firstly(  
        [{"x0": b[0][0] / ZM, "x1": b[1][0] / ZM,  
          "top": b[0][1] / ZM, "text": "", "txt": t,  
          "bottom": b[-1][1] / ZM,  
          "page_number": pagenum} for b, t in bxs if b[0][0] <= b[1][0] and b[0][1] <= b[-1][1]],  
        self.mean_height[-1] / 3  
    )  
  
    # merge chars in the same rect  
    for c in Recognizer.sort_X_firstly(  
            chars, self.mean_width[pagenum - 1] // 4):  
        ii = Recognizer.find_overlapped(c, bxs)  
        if ii is None:  
            self.lefted_chars.append(c)  
            continue  
        ch = c["bottom"] - c["top"]  
        bh = bxs[ii]["bottom"] - bxs[ii]["top"]  
        if abs(ch - bh) / max(ch, bh) >= 0.7 and c["text"] != ' ':  
            self.lefted_chars.append(c)  
            continue  
        if c["text"] == " " and bxs[ii]["text"]:  
            if re.match(r"[0-9a-zA-Z,.?;:!%%]", bxs[ii]["text"][-1]):  
                bxs[ii]["text"] += " "  
        else:  
            bxs[ii]["text"] += c["text"]  
  
    for b in bxs:  
        if not b["text"]:  
            left, right, top, bott = b["x0"] * ZM, b["x1"] * \  
                ZM, b["top"] * ZM, b["bottom"] * ZM  
            b["text"] = self.ocr.recognize(np.array(img),  
                                           np.array([[left, top], [right, top], [right, bott], [left, bott]],  
                                                    dtype=np.float32))  
        del b["txt"]  
    bxs = [b for b in bxs if b["text"]]  
    if self.mean_height[-1] == 0:  
        self.mean_height[-1] = np.median([b["bottom"] - b["top"]  
                                          for b in bxs])  
    self.boxes.append(bxs)
  • 首先使用self.ocr.detect方法检测图像中的文本框,并将结果存储在bxs变量中。如果没有检测到文本框,将空列表添加到self.boxes中并返回
  • 对检测到的文本框按照Y轴坐标进行排序
  • 遍历pdf提取到的文本chars,通过
    find_overlapped
    检测与字符char重叠的文本框,符合条件的char放入文本框:
    • 这里的条件,高度差异小于整体高度的0.3 (
      abs(ch - bh) / max(ch, bh) >= 0.7
      )
    • 否则就放入lefted_chars
  • 遍历文本框列表bxs,对于没有文本的文本框,尝试用ocr的recognize去识别文本,这里就做到了,
    能用原始文本的(准确)就用原始文本,原始是图片的,尝试用OCR去识别
  • 最后将包含文本的文本框添加到self.boxes中,并更新self.mean_height

版面识别

_layouts_rec:

  
def _layouts_rec(self, ZM, drop=True):  
    assert len(self.page_images) == len(self.boxes)  
    self.boxes, self.page_layout = self.layouter(  
        self.page_images, self.boxes, ZM, drop=drop)  
    # cumlative Y  
    for i in range(len(self.boxes)):  
        self.boxes[i]["top"] += \  
            self.page_cum_height[self.boxes[i]["page_number"] - 1]  
        self.boxes[i]["bottom"] += \  
            self.page_cum_height[self.boxes[i]["page_number"] - 1]
  • 调用self.layouter方法来获取新的self.boxes和self.page_layout,layouter 就是上面说的版面分析,这里会传入page_images图片,以及ocr处理后的文本box,layouter执行后,会返回分配layout后的文本框boxes,同时清理掉一些无用文本框
  • 然后更新box的top信息,加上pag_cum_height页面高度

表格处理
_table_transformer_job

这里会遍历page_layout,得到每一页的layout,从layout中找到表格,并调用模型识别后再根据规则做处理。

DocxParser word文档解析

word文档比pdf解析更容易,直接看
__call__
:

def __call__(self, fnm, from_page=0, to_page=100000):  
    self.doc = Document(fnm) if isinstance(  
        fnm, str) else Document(BytesIO(fnm))  
    pn = 0  
    secs = []  
    for p in self.doc.paragraphs:  
        if pn > to_page:  
            break  
        if from_page <= pn < to_page and p.text.strip():  
            secs.append((p.text, p.style.name))  
        for run in p.runs:  
            if 'lastRenderedPageBreak' in run._element.xml:  
                pn += 1  
                continue  
            if 'w:br' in run._element.xml and 'type="page"' in run._element.xml:  
                pn += 1  
  
    tbls = [self.__extract_table_content(tb) for tb in self.doc.tables]  
    return secs, tbls
  • 通过docx库加载word文档
  • 让后读取指定页面的paragraphs
  • 通过
    __extract_table_content
    解析表格

word里的表格,不需要模型来识别了,可以直接读取:

def __extract_table_content(self, tb):  
    df = []  
    for row in tb.rows:  
        df.append([c.text for c in row.cells])  
    return self.__compose_table_content(pd.DataFrame(df))  
  
def __compose_table_content(self, df):  
  
    def blockType(b):  
        patt = [  
            ("^(20|19)[0-9]{2}[年/-][0-9]{1,2}[月/-][0-9]{1,2}日*$", "Dt"),  
            (r"^(20|19)[0-9]{2}年$", "Dt"),  
            (r"^(20|19)[0-9]{2}[年/-][0-9]{1,2}月*$", "Dt"),  
            ("^[0-9]{1,2}[月/-][0-9]{1,2}日*$", "Dt"),  
            (r"^第*[一二三四1-4]季度$", "Dt"),  
            (r"^(20|19)[0-9]{2}年*[一二三四1-4]季度$", "Dt"),  
            (r"^(20|19)[0-9]{2}[ABCDE]$", "DT"),  
            ("^[0-9.,+%/ -]+$", "Nu"),  
            (r"^[0-9A-Z/\._~-]+$", "Ca"),  
            (r"^[A-Z]*[a-z' -]+$", "En"),  
            (r"^[0-9.,+-]+[0-9A-Za-z/$¥%<>()()' -]+$", "NE"),  
            (r"^.{1}$", "Sg")  
        ]  
        for p, n in patt:  
            if re.search(p, b):  
                return n  
        tks = [t for t in huqie.qie(b).split(" ") if len(t) > 1]  
        if len(tks) > 3:  
            if len(tks) < 12:  
                return "Tx"  
            else:  
                return "Lx"  
  
        if len(tks) == 1 and huqie.tag(tks[0]) == "nr":  
            return "Nr"  
  
        return "Ot"  
  
    if len(df) < 2:  
        return []  
    max_type = Counter([blockType(str(df.iloc[i, j])) for i in range(  
        1, len(df)) for j in range(len(df.iloc[i, :]))])  
    max_type = max(max_type.items(), key=lambda x: x[1])[0]  
  
    colnm = len(df.iloc[0, :])  
    hdrows = [0]  # header is not nessesarily appear in the first line  
    if max_type == "Nu":  
        for r in range(1, len(df)):  
            tys = Counter([blockType(str(df.iloc[r, j]))  
                          for j in range(len(df.iloc[r, :]))])  
            tys = max(tys.items(), key=lambda x: x[1])[0]  
            if tys != max_type:  
                hdrows.append(r)  
  
    lines = []  
    for i in range(1, len(df)):  
        if i in hdrows:  
            continue  
        hr = [r - i for r in hdrows]  
        hr = [r for r in hr if r < 0]  
        t = len(hr) - 1  
        while t > 0:  
            if hr[t] - hr[t - 1] > 1:  
                hr = hr[t:]  
                break  
            t -= 1  
        headers = []  
        for j in range(len(df.iloc[i, :])):  
            t = []  
            for h in hr:  
                x = str(df.iloc[i + h, j]).strip()  
                if x in t:  
                    continue  
                t.append(x)  
            t = ",".join(t)  
            if t:  
                t += ": "  
            headers.append(t)  
        cells = []  
        for j in range(len(df.iloc[i, :])):  
            if not str(df.iloc[i, j]):  
                continue  
            cells.append(headers[j] + str(df.iloc[i, j]))  
        lines.append(";".join(cells))  
  
    if colnm > 3:  
        return lines  
    return ["\n".join(lines)]
  • __extract_table_content
    函数接收一个表格对象(tb)作为输入,然后遍历表格的每一行,将每一行的单元格内容添加到一个列表(df)中
  • 然后
    __compose_table_content
    抽取表格内容,没仔细研究,大意是根据单元格的数据类型来判断列的类型,最后讲单元格拼接为字符串

总结

这里囫囵吐糟的review了下相关代码,可以看到RAGFlow在工程方面做了较多的工作,和微调的模型结合产生了良好的化学反应,通过一些工程的优化解决模型的badcase,最终做出了体验较好的产品,这是RAG文档解析的光明大道。

2. 支持向量机

对偶优化

拉格朗日乘数法可用于解决带条件优化问题,其基本形式为:

\[\begin{gather}
\min_w f(w),\\
\mathrm{s.t.} \quad \cases{g_i(w)\le 0,\\ h_i(w)=0.}
\end{gather}
\]

该问题的拉格朗日函数为

\[L(w,\alpha,\beta)=f(w)+\sum_{i}\alpha_ig_i(w)+\sum_j\beta_j h_j(w),
\]

定义

\[\begin{gather}
\mathcal{P}(w)=\max_{\alpha\ge 0,\beta} L(w,\alpha,\beta),\\
\mathcal{D}(\alpha,\beta)=\min_w L(w,\alpha,\beta),
\end{gather}
\]

容易看出,若
\(w\)
打破了原问题的某个限制条件,则
\(\mathcal P(w)=+\infty\)
,否则
\(\mathcal P(w)=f(w)\)
,因此原始问题可以表示为
\(\min_w\mathcal P(w)\)
;对应地,对偶问题可以表示为
\(\max_{\alpha\ge 0,\beta}\mathcal D(\alpha,\beta)\)
,也就是

\[\begin{gather}
\text{Primal: }\min_w\max_{\alpha\ge0,\beta} L(w,\alpha,\beta);\\
\text{Dual: }\max_{\alpha\ge 0,\beta}\min_w L(w,\alpha,\beta).
\end{gather}
\]

这就是原始问题对应的对偶问题,从上面可以得到原始问题与对偶问题最优解之间的关系:

\[d^*=\max_{\alpha\ge 0,\beta}\min_wL(w,\alpha,\beta)\le \min_w\max_{\alpha\ge 0,\beta}L(w,\alpha,\beta)=p^*.
\]

一般不能肯定原始问题与对偶问题具有相同的解,然而已有证明表明在一定条件下原始问题与对偶问题具有同一组解
\((w^*,\alpha^*,\beta^*)\)
,使得
\(p^*=d^*=L(w^*,\alpha^*,\beta^*)\)
,并且这组解满足KKT条件:

\[\begin{aligned}
\frac{\partial}{\partial w}L(w^*,\alpha^*,\beta^*)&=0;\\
\frac{\partial}{\partial\beta_j}L(w^*,\alpha^*,\beta^*)&=0,\quad \forall j;\\
\alpha_i^*g_i(w^*)&=0,\quad \forall i;\\
g_i(w^*)&\le 0,\quad \forall i;\\
\alpha_i^*&\ge 0,\quad \forall i.
\end{aligned}
\]

其中,第三条
\(\alpha_i^*g_i(w^*)=0\)
被称为互补松弛性.

支持向量机的优化目标

支持向量机的训练集
\(D=\{(x_i,y_i)\}\)
中,
\(y_i\in\{1,-1\}\)
,正例标签为
\(1\)
,负例标签为
\(-1\)
. 在几何直观上,支持向量机试图找到一个超平面
\(w'x+b=0\)
,使得所有样本在正确分类的前提下,所有点到超平面的直线距离的最小值最大.

  • 由于
    \(y_i\in\{1,-1\}\)
    ,故样本被正确分类意味着
    \(y_i(w'x_i+b)>0\)
    .
  • 对任意点
    \(x_i\)
    ,点到超平面
    \(w'x+b=0\)
    的距离为
    \(d=\frac{|w'x_i+b|}{\|w\|}\)
    .
  • 支持向量机的目的是让所有样本被正确分类的同时,最大化所有点到超平面的最小距离.

综合以上三点,支持向量机意图找到一个
\(\gamma >0\)
作为所有点到超平面的最小距离,并最大化这个
\(\lambda\)
,因此支持向量机的初始优化问题是

\[\begin{gather}
\max\gamma >0,\\
\mathrm{s.t.}\quad \frac{y_i(w'x_i+b)}{\|w\|}\ge \gamma.
\end{gather}
\]

此时约束条件是非凸的,令
\(\gamma :=\frac{\gamma}{\|w\|}\)
,上述问题就转化成

\[\begin{gather}
\max \frac{\gamma}{\|w\|},\\
\mathrm{s.t.}\quad y_i(w'x_i+b)\ge \gamma.
\end{gather}
\]

此时目标函数是非凸的,然而
\((w,b)\)
作为超平面的参数可以进行伸缩变换,且问题的解
\((w,b,\gamma)\)
在目标函数和约束条件上均是齐次的,这就使得任意一组解
\((w,b,\gamma)\)
都可以等效为
\((\frac{w}{\gamma},\frac{b}{\gamma},1)\)
,即设定
\(\gamma\equiv1\)
不会影响解的存在,因此可以在问题的形式中隐去参数
\(\gamma\)
,最后将目标函数等效变换,就得到SVM原始问题:

\[\begin{gather}
\max\frac{1}{\|w\|}\iff\min \frac{1}{2}\|w\|^2,\\
\mathrm{s.t.}\quad y_i(w'x_i+b)\ge 1,\quad \forall i\in\{1,\cdots,n\}.
\end{gather}
\]

现在求其对偶问题. 此问题的拉格朗日函数是

\[L(w,b,\alpha)=\frac{1}{2}\|w\|^2+\sum_{i=1}^{n}\alpha_i(1-y_i(w'x_i+b)).
\]

对偶问题即
\(\max_{\alpha\ge 0}\min_{w,b} L(w,b,\alpha)\)
,要计算
\(\min_{w,b}L(w,b,\alpha)\)
需对
\(w,b\)
求偏导,并令偏导数等于
\(0\)
,即

\[\begin{aligned}
\frac{\partial L}{\partial w}=w-\sum_{i=1}^{n}\alpha_i y_ix_i=0&\implies w=\sum_{i=1}^{n}\alpha_iy_ix_i;\\
\frac{\partial L}{\partial b}=-\sum_{i=1}^{n}\alpha_iy_i=0&\implies \sum_{i=1}^{n}\alpha_iy_i=0.
\end{aligned}
\]


\(w=\sum_{i=1}^{n}\alpha_iy_ix_i\)
代回
\(L(w,b,\alpha)\)
,再结合
\(\sum_{i=1}^{n}\alpha_iy_i=0\)
以及
\(\alpha_i\ge 0\)
的条件,就得到对偶问题为

\[\begin{gather}
\max f(\alpha)= \sum_{i=1}^{n}\alpha_i-\frac{1}{2}\sum_{i=1}^{n}\sum_{j=1}^{n}\alpha_i\alpha_jy_iy_j(x_i'x_j)\\
\mathrm{s.t.}\quad \cases{
\alpha_i\ge 0,\quad \forall i\ge 0,\\
\sum_{i=1}^{n}\alpha_iy_i=0.
}
\end{gather}
\]

线性不可分情形

线性不可分情形下,原始问题不可解,即不可能满足对所有样本都有
\(y_i(wx_i+b)\ge 1\)
,因此软间隔SVM对每个样本考虑一个容错
\(\xi_i\ge 0\)
,允许样本在一定程度上违背分类原则,但相应地在目标函数中施加惩罚,优化问题就产生如下变化:

\[\begin{gathered}
\min_{w,b} \frac{1}{2}\|w\|^2,\\
\mathrm{s.t.}\quad y_i(w'x_i+b)\ge 1.
\end{gathered}\implies
\begin{gathered}
\min_{w,b,\xi}\frac{1}{2}\|w\|^2+C\sum_{i=1}^{n}\xi_i,\\
\mathrm{s.t.}\quad \cases{y_i(w'x_i+b)\ge 1-\xi_i, \\
\xi_i\ge 0.}
\end{gathered}
\]

这里引入了超参数
\(C\)
表示对分类错误的容忍程度,
\(C\)
代表惩罚力度,
\(C=+\infty\)
时软间隔SVM就退化为硬间隔SVM. 同样求其对偶问题,拉格朗日函数是

\[L(w,b,\xi,\alpha,r)=\frac{1}{2}\|w\|^2+C\sum_{i=1}^{n}\xi_i-\sum_{i=1}^{n}\alpha_i(\xi_i+y_i(w'x_i+b)-1)-\sum_{i=1}^{n}r_i\xi_i,
\]

对偶问题是
\(\max_{\alpha\ge 0,r\ge 0}\min_{w,b,\xi}L(w,b,\xi,\alpha,r)\)
要求
\(\min_{w,b,\xi}(\alpha,r)\)
,就对
\(w,b,\xi\)
求偏导并令之等于
\(0\)
,得到

\[\begin{aligned}
\frac{\partial L}{\partial w}=w-\sum_{i=1}^{n}\alpha_iy_ix_i=0&\implies w=\sum_{i=1}^{n}\alpha_iy_ix_i,\\
\frac{\partial L}{\partial b}=-\sum_{i=1}^{n}\alpha_iy_i=0&\implies \sum_{i=1}^{n}\alpha_iy_i=0,\\
\frac{\partial L}{\partial \xi_i}=C-\alpha_i-r_i=0&\implies \alpha_i+r_i=C,\quad \forall i,
\end{aligned}
\]


\(w=\sum_{i=1}^{n}\alpha_iy_ix_i\)
代入到
\(L(w,b,\xi,\alpha,r)\)
中,并结合
\(\sum_{i=1}^{n}\alpha_iy_i=0\)

\(\alpha_i+r_i=C\)
的约束条件,就得到对偶问题

\[\begin{gather}
\max_{(\alpha,r)\ge 0}\sum_{i=1}^{n}\alpha_i-\frac{1}{2}\sum_{i=1}^{n}\sum_{j=1}^{n}\alpha_i\alpha_jy_iy_jx_i'x_j,\\
\mathrm{s.t.}\quad \cases{\sum_{i=1}^{n}\alpha_iy_i=0,\\
\alpha_i\ge 0,\\
r_i\ge 0,\\
\alpha_i+r_i=C}\implies \cases{\sum_{i=1}^{n}\alpha_iy_i=0,\\
0\le \alpha_i\le C.}
\end{gather}
\]

可以看到,软间隔SVM相比硬间隔SVM,它们的对偶问题优化目标一致,唯一区别在于为每一个
\(\alpha_i\)
添加了上界
\(C\)
,因此仅在求解时有些许区别.

原始问题与对偶问题的关联

现在根据原始问题推导得到对偶问题,并且假定已经了解对偶问题的解法. 然而最终回到问题本身,还是要找到对应的分组超平面
\((w,b):w'x+b=0\)
,因此需要了解原始问题与对偶问题的关联.

首先,讨论的前提是原始问题与对偶问题同解,对支持向量机而言,已有证明表明这一条件是成立的,即原始问题与对偶问题共享拉格朗日函数的一组解,记作
\((w^*,b^*,\alpha^*[,\xi^*,r^*])\)
(中括号内是软间隔SVM独有的变量),且KKT条件也成立,这意味着互补松弛性
\(\alpha_i^*g_i(w^*)=0\)
对任何样本
\(i\)
都成立.

在上述前提下,考虑下面的几个问题:

  1. 互补松弛性
    \(\alpha_i^*g_i(w^*)=0\)
    意味着什么?
  2. 求得对偶问题的解
    \(\alpha^*\)
    后如何回推分类超平面
    \((w^*,b^*)\)
  3. 如何利用优化后的支持向量机
    \((w^*,b^*,\alpha^*)\)
    判别样本点?

首先,第一个问题中,互补松弛性成立表明对每个样本
\(i\)
,都有
\(\alpha_i^*g_i(w^*)=0\)
,回到问题定义上,
\(\alpha_i\ge 0\)
是对偶问题中约束条件
\(g_i(w^*)\le 0\)

\(y_i(w_ix+b)\ge 1-\xi_i\)
的对应系数,既然
\(\alpha_i\ge 0\)

\(g_i(w^*)\le 0\)
,那么要使
\(\alpha_i^*g_i(w^*)=0\)
,就必须满足
\(\alpha_i^*=0\)

\(g_i(w^*)=0\)
. 而
\(\alpha\)
是通过对偶问题求解直接获得的,并且与每个样本对应,因此对每个样本,必定满足下面两个条件中的至少一个(硬间隔SVM中不含
\(\xi_i\)
):

  • \(\alpha_i^*=0\)
    ,此时
    \(y_i(w'x_i+b)> 1-\xi_i\)
    ,这样的样本称为非支持向量,它经过
    \(\xi_i\)
    调节后落在分隔超平面的间隔外.
  • \(\alpha_i^*>0\)
    ,此时
    \(y_i(w'x_i+b)=1-\xi_i\)
    ,这样的样本称为支持向量,它经过
    \(\xi_i\)
    调节后落在分隔超平面的间隔上.

实际数据集中,非支持向量的数量总是远多于支持向量,即大多数样本总是落在分隔超平面以外,支持向量的数量相对较少,但只有它们对分类器产生实质影响,因此分类器才被称为支持向量机. 为什么说只有支持向量会实质上地影响分类器,要看第二个问题与第三个问题,下面用
\(SV\)
表示支持向量(support vector)所构成的集合.

第二个问题,即如何根据
\(\alpha^*\)
反推
\((w^*,b^*)\)
,这由优化过程中的偏导条件可得. 注意到令
\(\frac{\partial L}{\partial w}=0\)
时得到了
\(w=\sum_{i=1}^{n}\alpha_iy_ix_i\)
,这正是最优解所满足的条件,即

\[w^*=\sum_{i=1}^{n}\alpha_i^*y_ix_i,
\]

进一步地,因为当且仅当样本为支持向量时
\(\alpha_i^*>0\)
,即对大多数样本有
\(\alpha_i^*=0\)
,这部分样本对
\(w^*\)
没有贡献,也就是

\[w^*=\sum_{i\in SV}\alpha_i^*y_ix_i.
\]

接下来考虑
\(b^*\)
,同样根据互补松弛性,当且仅当样本为支持向量时成立
\(y_i(w'x_i+b^*)=1-\xi_i\)
,因此对每个支持向量,如果能求得
\(\xi_i\)
的值就能得到
\(b^*\)
的值. 注意到当
\(\alpha_i<C\)

\(r_i>0\)
,从而
\(\xi_i=0\)
,因此只要能够找到一个
\(\alpha_i\in (0,C)\)
,就能通过计算得到
\(b^*=y_i-(w^*)'x_i\)
(注意到
\(\frac{1}{y_i}=y_i\)
). 往往可以对所有这样的样本做一个平均以平滑,即

\[b^*=\frac{1}{|\{i:0<\alpha_i<C\}|}\sum_{i:0<\alpha_i<C}\left(y_i-\sum_{j\in SV}\alpha_j^*y_jx_j'x_i \right),
\]

特别对硬间隔支持向量机,此时在正负样本中都必定存在一个支持向量,落在超平面间隔上,从而

\[\begin{gather}
\min_{i:y_i=1}w'x_i+b^*=1,\\
\max_{i:y_i=-1}w'x_i+b^*=-1,\\
b^*=-\frac{\min_{i:y_i=1}(w'x_i)+\max_{i:y_i=-1}(w'x_i)}{2}.
\end{gather}
\]

最后一个问题即如何判别样本,这个问题相对简单,因为训练集中正样本总满足
\((w^*)'x_i+b^*\ge 1\)
,负样本总满足
\((w^*)'x_i+b^*\le -1\)

\(\xi_i\)
是针对特殊情况的补偿,因此在模型使用时不需考虑,未知样本
\(x_0\)
的分类就是

\[\hat{y}_0=\mathrm{sign}\left((w^*)'x_0+b^* \right)=\mathrm{sign}\left(\sum_{i\in SV}\alpha_i^*y_ix_i'x_0+b^* \right).
\]

可以看到,求解完毕的支持向量机中,无论是参数表示还是模型使用,均只与支持向量有关.

核技巧

线性不可分时,除了能使用软间隔SVM以外,还可以用特征映射
\(\varphi(\cdot)\)
将样本
\((x_i,y_i)\)
映射到高维
\((\varphi(x_i),y_i)\)
,因为高维空间中的数据更系数,线性可分的可能性也更大. 然而,
\(\varphi(\cdot)\)
往往是形式未知、维度极高甚至是无限维的,在这种情形下,核技巧(kernel trick)提供了一种绕开
\(\varphi(\cdot)\)
的计算,不显式地计算转化后的数据集
\(\varphi(x_i)\)
,也能在转化后的高维空间中使用支持向量机的方法.

先回顾支持向量机应用于非特征映射数据集时的步骤:

  1. 构建对偶问题求解,在
    \(\sum_{i=1}^{n}\alpha_iy_i=0\)

    \(\alpha_i\in[0,C]\)
    的情况下求解
    \(\max_{\alpha}\left(\sum_{i}\alpha_i-\frac{1}{2}\sum_{i,j}\alpha_i\alpha_jy_iy_j(x_i'x_j) \right)\)
    .1.
  2. 得到最优解
    \(\alpha\)
    后,找到一个
    \(\alpha_k\in (0,C)\)
    ,反推
    \(b=y_k-\sum_{i}\alpha_iy_i(x_i'x_k)\)
    .
  3. 得到分割超平面
    \(\sum_i\alpha_iy_i(x_i'x)+b=0\)
    ,判别新样本
    \(y_0=\mathrm{sign}(\sum_{i}\alpha_iy_i(x_i'x_0)+b)\)
    .

注意到上面的过程中,有意地隐去了
\(w\)
的计算,因为不论在模型表示还是样本归类中,都不需要显式地得到
\(w\)
的具体值;并且,任何出现了样本特征
\(x\)
的地方,总是以内积
\(x_i'x_j\)
的形式出现. 这说明,即使使用了特征映射
\(\varphi(\cdot)\)
,也不必显式地得到每个样本
\(x\)
对应的高维特征
\(\varphi(x)\)
,但是对任意样本对
\((x_i,x_j)\)
,它们的内积
\(\varphi(x_i)'\varphi(x_j)\)
却是必要的.

因此,核技巧不显式地求解
\(\varphi(\cdot)\)
,而是用一个对称二元函数
\(\kappa:(\mathbb{R}^{d},\mathbb{R}^{d})\mapsto \mathbb{R}\)
定义了内积:
\(\kappa(x_i,x_j)=\varphi(x_i)'\varphi(x_j):=\langle x_i,x_j\rangle\)
,这样,将核技巧运用于支持向量机就可以将上面的步骤直接改写为:

  1. 构建对偶问题求解,在
    \(\sum_{i=1}^{n}\alpha_iy_i=0\)

    \(\alpha_i\in[0,C]\)
    的情况下求解
    \(\max_{\alpha}\left(\sum_{i}\alpha_i-\frac{1}{2}\sum_{i,j}\alpha_i\alpha_jy_iy_j\langle x_i,x_j\rangle \right)\)
    .
  2. 得到最优解
    \(\alpha\)
    后,找到一个
    \(\alpha_k\in (0,C)\)
    ,反推
    \(b=y_k-\sum_{i}\alpha_iy_i\langle x_i,x_k\rangle\)
    .
  3. 得到分割超平面
    \(\sum_i\alpha_iy_i\langle x_i,x\rangle+b=0\)
    ,判别新样本
    \(y_0=\mathrm{sign}(\sum_{i}\alpha_iy_i\langle x_i,x_0\rangle+b)\)
    .

综上所述,核技巧就是在不使用特征映射
\(\varphi(\cdot)\)
的显式表达的同时,利用内积完成了特征映射所需要达到的目标,使得用户不需要考虑如何变换样本的特征,只需尝试不同的核函数即可. 常用的核函数有:

  • 线性核:
    \(\kappa( x_i, x_j)= x_i' x_j\)
    .
  • 多项式核:
    \(\kappa( x_i, x_j)=( x_i' x_j)^{d}\)
    .
  • 高斯核:
    \(\kappa( x_i, x_j)=\exp(-\frac{\| x_i- x_j\|^2}{2\sigma^2})\)
    .
  • 拉普拉斯核:
    \(\kappa( x_i, x_j)=\exp(-\frac{\| x_i- x_j\|}{\sigma})\)
    .
  • Sigmoid核:
    \(\kappa( x_i, x_j)=\tanh(\beta x_i' x_j+\theta)\)
    .

甚至核函数的形式也不是必要的,只要有办法得到每一个样本对的核函数值即可,因此在训练过程中,传入一个核矩阵
\(K\in\mathbb{R}^{n\times n}\)
代表样本对的核函数值,并在使用过程中传入样本
\(x_0\)
的同时,传入一个向量
\(k_0\in\mathbb{R}^{n}\)
代表
\(x_0\)
与所有训练样本的核函数值,就可以等效地作为核函数使用.

不过,由于核函数本身是特征映射内积的替代,所以核函数本身需要满足一定的条件. Mercer表明任何半正定函数都可以作为核函数使用,半正定函数指的是对任意数据集
\((x_1,\cdots,x_n)\)
,核函数
\(\kappa\)
诱导的矩阵
\(K\in\mathbb{R}^{n\times n}:K_{ij}=\kappa(x_i,x_j)\)
都是对称半正定的. 即使传入核矩阵
\(K\)
代替核函数,那么
\(K\)
本身也要是对称半正定矩阵,并且加入训练样本后,它与其他样本构成的核向量也要在增广意义下是对称半正定的.

SMO算法

最后进入到求解对偶问题的算法,常使用SMO(序列最小优化)算法对SVM的对偶问题进行求解,其思想是在对偶问题的
\(n\)
个变量中每次只选择两个进行优化,通过约束
\(\sum_{i=1}^{n}\alpha_iy_i=0\)
,固定
\(n-2\)
个变量使得每次优化只有一个
\(\alpha_i\)
可以自由变化,因此可以通过求梯度的方式直接优化.

对软间隔SVM,令
\(K_{ij}=\langle x_i,x_j\rangle\)

\(x_i,x_j\)
的核函数值,目标是最大化

\[\max_{\alpha}\sum_{i=1}^{n}\alpha_i-\frac{1}{2}\sum_{i,j}\alpha_i\alpha_jy_iy_jK_{ij},
\]

现选择变量对
\((\alpha_1,\alpha_2)\)
优化并将其他变量用无关值
\(C\)
代替,那么约束变为
\(\alpha_1y_1+\alpha_2y_2=\zeta\)
,目标就变成

\[\begin{aligned}
&\quad \max_{\alpha_1,\alpha_2} W(\alpha_1,\alpha_2)\\&=\alpha_1+\alpha_2-\frac{1}{2}\left(\alpha_1^2K_{11}+\alpha_2^2K_{22}+2\alpha_1\alpha_2y_1y_2K_{12}\right)\\
&\quad -\left(\alpha_1y_1\sum_{j=3}^{n}\alpha_jy_jK_{1j}+\alpha_2y_2\sum_{j=3}^{n}\alpha_jy_jK_{2j}\right)+C.
\end{aligned}
\]

再将约束条件:
\(\alpha_2=\zeta y_2-\alpha_1y_1y_2\)
代入,并令
\(\sum_{j=3}^{n}\alpha_jy_jK_{ij}=v_i(i=1,2)\)
,就得到关于
\(\alpha_1\)
的一元二次优化问题

\[\begin{aligned}
&\quad W(\alpha_1)\\
&=-\frac{1}{2}\left(\alpha_1^2K_{11}+(\zeta y_2-\alpha_1y_1y_2)^2K_{22}+2\alpha_1(\zeta y_2-\alpha_1y_1y_2)y_1y_2K_{12} \right)\\
&\quad +\alpha_1+\zeta y_2-\alpha_1(y_1y_2)-\alpha_1y_1v_1-(\zeta y_2-\alpha_1y_1y_2)y_2v_2+C\\
&=-\frac{1}{2}\left(\alpha_1^2K_{11}+\alpha_1^2K_{22}+\zeta^2K_{22}-2\alpha_1\zeta y_1K_{22}+2\zeta y_1\alpha_1K_{12}-2\alpha_1^2K_{12} \right)\\
&\quad +\alpha_1+\zeta y_2-\alpha_1(y_1y_2)-\alpha_1y_1v_1-\zeta v_2+\alpha_1y_1v_2+C\\
&=\left(K_{12}-\frac{1}{2}K_{11}-\frac{1}{2}K_{22} \right)\alpha_1^2\\
&\quad +\left(\zeta y_1K_{22}-\zeta y_1K_{12}+1-y_1y_2-y_1v_1+y_1v_2 \right)\alpha_1\\
&\quad +C+\zeta y_2-\zeta v_2-\frac{1}{2}\zeta^2K_{22}.
\end{aligned}
\]


\(\frac{\partial W(\alpha_1)}{\partial \alpha_1}=0\)
就得到
\(\alpha_1\)
应满足的条件为

\[\alpha_1(K_{11}+K_{12}-2K_{12})=y_1(\zeta K_{12}-\zeta K_{22}+y_1-y_2-v_1+v_2),
\]

在求得
\(\zeta\)

\(v_1\)

\(v_2\)
的同时,已经可以计算得到
\(\alpha_1\)

\(\alpha_2\)
的值. 注意,在软间隔SVM中还需要满足
\(\alpha_1\in[0,C]\)

\(\alpha_2\in[0,C]\)
,并结合约束
\(\alpha_1y_1+\alpha_2y_2=\zeta\)
. 这里分两种情况:

  1. \(y_1\ne y_2\)
    时,约束变为
    \(\alpha_1-\alpha_2=k\)
    ,其中
    \(k\)
    的正负性未知,因此
    \(\max(0,k)\le\alpha_1\le\min(C,C+k)\)
    .
  2. \(y_1=y_2\)
    时,约束变为
    \(\alpha_1+\alpha_2=k\)
    ,其中
    \(k\)

    \(C\)
    的大小关系未知,因此
    \(\max(0,k-C)\le\alpha_1\le \min(C,k)\)
    .

  3. \(k\)
    的求值,注意到参数更新前后始终有
    \(\alpha_1y_1+\alpha_2y_2=\zeta\)

    \((y_1,y_2)\)
    不变,因此可以直接用更新前的参数计算
    \(k\)
    .
  4. 得到的上下界同时适用于
    \(\alpha_1\)

    \(\alpha_2\)
    ,若求得的
    \(\alpha_1,\alpha_2\)
    超出此边界,则进行矩形修剪.

上述形式还可以继续化简,注意对
\(\alpha^\text{new}\)

\(\alpha^\text{old}\)
作区分. SVM对样本
\(x\)
的预测为
\(f(x)=\sum_{i=1}^{n}\alpha_i^\text{old}y_i\langle x_i,x\rangle +b\)
,因此

\[\begin{aligned}
v_1&=f(x_1)-\alpha_1^\text{old}y_1K_{11}-\alpha_2^\text{old}y_2K_{12}-b,\\
v_2&=f(x_2)-\alpha_1^\text{old}y_1K_{12}-\alpha_2^\text{old}y_2K_{22}-b,\\
v_1-v_2&=f(x_1)-f(x_2)-\alpha_1^\text{old}y_1(K_{11}-K_{12})-\alpha_2^\text{old}y_2(K_{12}-K_{22}),
\end{aligned}
\]

再将
\(\alpha_2=\zeta y_2-\alpha_1y_1y_2\)
代入(此式子对
\(\alpha^\text{new}\)

\(\alpha^\text{old}\)
均成立),就得到

\[\begin{aligned}
v_1-v_2&=f(x_1)-f(x_2)-\alpha_1^\text{old}y_1(K_{11}-K_{12})-(\zeta -\alpha_1^\text{old} y_1)(K_{12}-K_{22})\\
&=f(x_1)-f(x_2)-\alpha_1^\text{old}y_1(K_{11}+ K_{22}-2K_{12})-\zeta(K_{12}-K_{22})
\end{aligned}
\]

将其带回
\(\frac{\partial W(\alpha_1)}{\partial \alpha_1}\)
得到

\[\begin{aligned}
&\quad \alpha_1^\text{new}(K_{11}+K_{12}-2K_{12})\\
&=y_1(y_1-y_2-(f(x_1)-f(x_2)-\alpha_1^\text{old}y_1(K_{11}+K_{12}-2K_{12})))\\
&=y_1\left(y_1-f(x_1)-(y_2-f(x_2)) \right)+\alpha_1^\text{old}(K_{11}+K_{12}-2K_{12}),
\end{aligned}
\]


\(E_i=f(x_i)-y_i\)
为旧模型在第
\(i\)
个样本上的预测误差,就有

\[\alpha_1^\text{new}=\alpha_1^\text{old}-\frac{y_1(E_1-E_2)}{K_{11}+K_{12}-2K_{12}},
\]

上式略过了一些繁琐参数(如
\(C\)

\(\zeta\)
)的计算,形式美观,但应用上依赖于旧模型
\(f(\cdot)\)
,这意味着每次更新参数后需要得到新的模型,即需要对
\(b\)
进行更新. 注意到,至少有一个
\(\alpha_i\)
不在边界
\([0,C]\)
上时
\(x_i\)
是支持向量,此时有

\[y_i\sum_{j=1}^{n}\alpha_j^\text{new}y_j\langle x_j,x_i\rangle+b^\text{new}=1\implies b^\text{new}=1-y_i\sum_{j=1}^{n}\alpha_j^\text{new} y_i\langle x_j,x_i\rangle.
\]

若两个
\(\alpha_i\)
经更新后均不在边界上,则通过两个
\(x_i\)
计算得到的
\(b^\text{new}\)
相等;若两个
\(\alpha_i\)
经更新后均在边界上,则取用这两个样本计算得到的
\(b\)
的中点作为
\(b^\text{new}\)
.

综上,给出SMO算法更新软间隔SVM的含代数步骤:

  1. 随机选择一对样本对应的变量作为更新变量
    \((\alpha_i,\alpha_j)\)
    .

  2. \(E_k=f(x_k)-y_k\)
    为旧SVM的预测误差,计算无修剪的更新值
    \(\alpha_i^\text{new}=\alpha_i^\text{old}-\frac{y_i(E_i-E_j)}{K_{ii}+K_{jj}-2K_{ij}}\)
    .

  3. \(\alpha_i^\text{new}\)
    的修剪上下限:若
    \(y_i\ne y_j\)
    则约束为
    \(\alpha_i-\alpha_j=k\)
    ,因此
    \(L=\max(0,\alpha_i^\text{old}-\alpha_j^\text{old})\)

    \(H=\min(C,C+\alpha_i^\text{old}-\alpha_j^\text{old})\)
    ;若
    \(y_i=y_j\)
    则约束为
    \(\alpha_i+\alpha_j=k\)
    ,因此
    \(L=\max(0,\alpha_i^\text{old}+\alpha_j^\text{old}-C)\)

    \(H=\min(C,\alpha_i^\text{old}+\alpha_j^\text{old})\)
    .
  4. 修剪解:将
    \(\alpha_i^\text{new}\)
    修剪至
    \([L,H]\)
    之间,并根据
    \(\alpha_i^\text{old}y_i+\alpha_j^\text{old}y_j=\alpha_i^\text{new}y_i+\alpha_j^\text{new}y_j\)
    得到
    \(\alpha_j^\text{new}\)
    .
  5. 更新
    \(b\)
    :若
    \(\alpha_i^\text{new}\)

    \(\alpha_j^\text{new}\)
    中至少有一个是支持向量(设为
    \(i\)
    ),则更新
    \(b^\text{new}=1-y_i\sum_{j}\alpha_j^\text{new}y_jK_{ji}\)
    ;若二者均不是支持向量,则依然用此法计算两个
    \(b\)
    ,取它们的平均作为
    \(b^\text{new}\)
    .
  6. 循环上述步骤,取不同的更新变量对进行更新.

SVM分类的建议代码实现

下面给出了使用SMO算法训练SVM分类器的具体实现,这里使用了随机抽选的方式选择每次更新的变量对
\((\alpha_i,\alpha_j)\)
,并且使用内积核函数,指定
\(C=+\infty\)
代表一个硬间隔分类器. 模型对一个人造数据集合真实数据集
breast_cancer
都有较为不错的分辨能力,需注意要对模型标签进行
\(\{-1,1\}\)
化,同时这里还对特征进行归一化以防止数值溢出.

import numpy as np
from sklearn.datasets import load_breast_cancer

class SVM:
    def __init__(self, X, y, kernel=None):
        self.X = np.array(X)
        self.y = np.array(y)  # {-1, 1}
        if not kernel:
            kernel = np.dot
        self.kernel = kernel
        self.n_samples, self.dim = self.X.shape
        self.alpha = np.zeros(self.n_samples)
        self.b = 0
    
    def fit(self, max_iter=10000, C=float('inf')):
        for k in range(max_iter):
            i, j = np.random.choice(self.n_samples, 2, replace=False)
            Ei = self.predict(self.X[i]) - self.y[i]
            Ej = self.predict(self.X[j]) - self.y[j]
            eta = self.kernel(self.X[i], self.X[i]) + self.kernel(self.X[j], self.X[j]) - 2 * self.kernel(self.X[i], self.X[j])
            alpha_i_unclipped = self.alpha[i] - self.y[i] * (Ei-Ej) / eta
            alpha_i_clipped, alpha_j_clipped = 0, 0
            if self.y[i] == self.y[j]:
                K_constant = self.alpha[i] + self.alpha[j]
                lower_bound = max(0, K_constant - C)
                upper_bound = min(C, K_constant)
                alpha_i_clipped = self.__clip(alpha_i_unclipped, lower_bound, upper_bound)
                alpha_j_clipped = K_constant - alpha_i_clipped
            else:
                K_constant = self.alpha[i] - self.alpha[j]
                lower_bound = max(0, K_constant)
                upper_bound = min(C, C + K_constant)
                alpha_i_clipped = self.__clip(alpha_i_unclipped, lower_bound, upper_bound)
                alpha_j_clipped = alpha_i_clipped - K_constant
            self.alpha[i], self.alpha[j] = alpha_i_clipped, alpha_j_clipped
            if self.alpha[i] > 0 and self.alpha[i] < C:
                self.b = self.__calculate_b(i)
            elif self.alpha[j] > 0 and self.alpha[j] < C:
                self.b = self.__calculate_b(j)
            else:
                self.b = (self.__calculate_b(i) + self.__calculate_b(j)) / 2.0
    
    def __clip(self, alpha, L, H):
        if alpha < L:
            return L
        elif alpha > H:
            return H
        else:
            return alpha
    
    def __calculate_b(self, ind):
        return 1 - self.y[ind] * np.dot(self.y * self.alpha, self.kernel(self.X, self.X[ind]))
    
    def predict(self, x):
        x = np.array(x)
        if len(x.shape) == 1:
            return self.b + np.sum(self.alpha * self.y * self.kernel(self.X, x))
        elif len(x.shape) == 2:
            return self.b + np.dot(self.alpha * self.y, self.kernel(self.X, x.T))
    
    def predict_label(self, x):
        y_pred = self.predict(x)
        return np.sign(y_pred)
    
def acc(pred, label):
    return (pred == label).mean()

np.random.seed(0)
d = 5; n = 200
X_syn = [np.random.multivariate_normal(mean=np.random.normal(size=d), cov=np.identity(d) / 5, size=(100,)) for i in range(2)]
X_syn = np.vstack(X_syn)
y_syn = np.repeat([-1, 1], 100)
clf = SVM(X_syn, y_syn)
clf.fit()
print(f'acc = {acc(clf.predict_label(X_syn), y_syn)}')

df = load_breast_cancer()
X_real, y_real = df['data'], df['target']
X_real = (X_real - X_real.mean(0)) / X_real.std(0)
y_real = y_real * 2 - 1
clf_real = SVM(X_real, y_real, kernel=np.dot)
clf_real.fit()
print(f'acc of breast cancer = {acc(clf_real.predict_label(X_real), y_real)}')

废话:

有时候我们是从物品的斜上方拍摄的图片,看起来不直观,需要把视角拉正,这样的一个操作就叫做 梯度矫正,需要用到的技术是 Opencv 的 透视变换。

这个只是一个简单的演示demo,如果完善一下,比如物品检测,可以应用更多的场景,比如常见的:文件、资料上传,软管摄像头的应用等,怎么说也是一个技术点吧

重要代码:

/**
* @brief hDLL_gradientAuto 梯度矫正
* @param src 输入图像
* @param dst 输出图像
* @param flag 方向,[0(左),1(上),2(右),3(下)]
* @param val 矫正度数,像素,[10 ~ 100]
* @return 0(成功),-1(失败)
*/ int MainWindow::hDLL_gradientAuto(Mat &src, Mat &dst, int flag, intval)
{
if(flag != 0 && flag != 1 && flag != 2 && flag != 3) return -1;if(val < 10 || val > 100) return -1;int width =src.cols;int height =src.rows;
Mat M;
//flag 方向,[0(左),1(上),2(右),3(下)] switch(flag) {case 0:
{
Point2f pts_src[]
= { Point(val,val), Point(width, 0), Point(width, height), Point(val, height-val)};
Point2f pts_dst[]
= { Point(0, 0), Point(width, 0), Point(width, height) ,Point(0, height) };
M
=cv::getPerspectiveTransform(pts_src, pts_dst);
}
break;case 1:
{
Point2f pts_src[]
= { Point(val,val), Point(width-val, val), Point(width, height), Point(0, height)};
Point2f pts_dst[]
= { Point(0, 0), Point(width, 0), Point(width, height) ,Point(0, height) };
M
=cv::getPerspectiveTransform(pts_src, pts_dst);
}
break;case 2:
{
Point2f pts_src[]
= { Point(0,0), Point(width-val, val), Point(width-val, height-val), Point(0, height)};
Point2f pts_dst[]
= { Point(0, 0), Point(width, 0), Point(width, height) ,Point(0, height) };
M
=cv::getPerspectiveTransform(pts_src, pts_dst);
}
break;case 3:
{
Point2f pts_src[]
= { Point(0,0), Point(width, 0), Point(width-val, height-val), Point(val, height-val)};
Point2f pts_dst[]
= { Point(0, 0), Point(width, 0), Point(width, height) ,Point(0, height) };
M
=cv::getPerspectiveTransform(pts_src, pts_dst);
}
break;
}

cv::warpPerspective(src, dst, M, dst.size(), cv::INTER_LINEAR , cv::BORDER_REPLICATE);
return 0;
}

Demo演示:

完整代码:

.h

#ifndef MAINWINDOW_H#define MAINWINDOW_H#include<QMainWindow>#include<QImage>#include<QDebug>#include<QtMath>#include"opencv2/opencv.hpp"
using namespacecv;

QT_BEGIN_NAMESPACE
namespaceUi {classMainWindow;
}
QT_END_NAMESPACE
class MainWindow : publicQMainWindow
{
Q_OBJECT
public:
MainWindow(QWidget
*parent =nullptr);~MainWindow();voidupdateQLabelImage();

Mat QImage2Mat(QImage
&img);
QImage Mat2QImage(Mat
&img);/**
* @brief hDLL_gradientAuto 梯度矫正
* @param src 输入图像
* @param dst 输出图像
* @param flag 方向,[0(左),1(上),2(右),3(下)]
* @param val 矫正度数,像素,[10 ~ 100]
* @return 0(成功),-1(失败)
*/ int hDLL_gradientAuto(Mat &src, Mat &dst, int flag, intval);publicslots:void horChange(intindex);void verChange(intindex);private:
Ui::MainWindow
*ui;

QImage m_img;
//原图 QImage m_img_dst; //处理过的图像 };#endif //MAINWINDOW_H

.cpp

#include "mainwindow.h"#include"ui_mainwindow.h"MainWindow::MainWindow(QWidget*parent)
: QMainWindow(parent)
, ui(
newUi::MainWindow)
{
ui
->setupUi(this);

connect(ui
->horizontalSlider, SIGNAL(valueChanged(int)), this, SLOT(horChange(int)));
connect(ui
->verticalSlider, SIGNAL(valueChanged(int)), this, SLOT(verChange(int)));

m_img
= QImage("F:1.jpg");
m_img_dst
=m_img;
updateQLabelImage();
}

MainWindow::
~MainWindow()
{
deleteui;
}
//更新QLabel里面的图像 voidMainWindow::updateQLabelImage()
{
//m_img = m_img.scaled(ui->label->width(), ui->label->height()); QImage img_show = m_img_dst.scaled(ui->label->width(), ui->label->height());
ui
->label->setPixmap(QPixmap::fromImage(img_show));
}

Mat MainWindow::QImage2Mat(QImage
&img)
{
cv::Mat mat;
switch(img.format())
{
case QImage::Format_RGB32: //一般Qt读入彩色图后为此格式 mat = cv::Mat(img.height(), img.width(), CV_8UC4, (void*)img.constBits(), img.bytesPerLine());
cv::cvtColor(mat,mat,cv::COLOR_BGRA2BGR);
//转3通道 break;caseQImage::Format_RGB888:
mat
= cv::Mat(img.height(), img.width(), CV_8UC3, (void*)img.constBits(), img.bytesPerLine());
cv::cvtColor(mat,mat,cv::COLOR_RGB2BGR);
break;caseQImage::Format_Indexed8:
mat
= cv::Mat(img.height(), img.width(), CV_8UC1, (void*)img.constBits(), img.bytesPerLine());break;
}
returnmat;
}

QImage MainWindow::Mat2QImage(Mat
&img)
{
if(img.type()==CV_8UC1 || img.type()==CV_8U)
{
QImage image((
const uchar *)img.data, img.cols, img.rows, img.step, QImage::Format_Grayscale8);returnimage;
}
else if(img.type()==CV_8UC3)
{
QImage image((
const uchar *)img.data, img.cols, img.rows, img.step, QImage::Format_RGB888);return image.rgbSwapped(); //r与b调换 }
}
int MainWindow::hDLL_gradientAuto(Mat &src, Mat &dst, int flag, intval)
{
if(flag != 0 && flag != 1 && flag != 2 && flag != 3) return -1;if(val < 10 || val > 100) return -1;int width =src.cols;int height =src.rows;
Mat M;
//flag 方向,[0(左),1(上),2(右),3(下)] switch(flag) {case 0:
{
Point2f pts_src[]
= { Point(val,val), Point(width, 0), Point(width, height), Point(val, height-val)};
Point2f pts_dst[]
= { Point(0, 0), Point(width, 0), Point(width, height) ,Point(0, height) };
M
=cv::getPerspectiveTransform(pts_src, pts_dst);
}
break;case 1:
{
Point2f pts_src[]
= { Point(val,val), Point(width-val, val), Point(width, height), Point(0, height)};
Point2f pts_dst[]
= { Point(0, 0), Point(width, 0), Point(width, height) ,Point(0, height) };
M
=cv::getPerspectiveTransform(pts_src, pts_dst);
}
break;case 2:
{
Point2f pts_src[]
= { Point(0,0), Point(width-val, val), Point(width-val, height-val), Point(0, height)};
Point2f pts_dst[]
= { Point(0, 0), Point(width, 0), Point(width, height) ,Point(0, height) };
M
=cv::getPerspectiveTransform(pts_src, pts_dst);
}
break;case 3:
{
Point2f pts_src[]
= { Point(0,0), Point(width, 0), Point(width-val, height-val), Point(val, height-val)};
Point2f pts_dst[]
= { Point(0, 0), Point(width, 0), Point(width, height) ,Point(0, height) };
M
=cv::getPerspectiveTransform(pts_src, pts_dst);
}
break;
}

cv::warpPerspective(src, dst, M, dst.size(), cv::INTER_LINEAR , cv::BORDER_REPLICATE);
return 0;
}
//横向改变 void MainWindow::horChange(intindex)
{
qDebug()
<< "hor:" <<index;if(index == 0)
{
m_img_dst
=m_img;
}
else if(index < 0)
{
int val = abs(index) * 10;
Mat src
=QImage2Mat(m_img);
Mat dst;
hDLL_gradientAuto(src, dst,
0, val);
m_img_dst
=Mat2QImage(dst);
}
else if (index > 0)
{
int val = abs(index) * 10;
Mat src
=QImage2Mat(m_img);
Mat dst;
hDLL_gradientAuto(src, dst,
2, val);
m_img_dst
=Mat2QImage(dst);
}
updateQLabelImage();
}
//竖向改变 void MainWindow::verChange(intindex)
{
qDebug()
<< "ver:" <<index;if(index == 0)
{
m_img_dst
=m_img;
}
else if(index < 0)
{
int val = abs(index) * 10;
Mat src
=QImage2Mat(m_img);
Mat dst;
hDLL_gradientAuto(src, dst,
3, val);
m_img_dst
=Mat2QImage(dst);
}
else if (index > 0)
{
int val = abs(index) * 10;
Mat src
=QImage2Mat(m_img);
Mat dst;
hDLL_gradientAuto(src, dst,
1, val);
m_img_dst
=Mat2QImage(dst);
}

updateQLabelImage();
}

代码下载:

我的环境是:Qt 5.15.2 + Opencv V4.8.0,如果需要下载代码,自己调试,自己配置环境即可

代码仓库:https://gitee.com/vvvj/qt-test-gradient-auto