2024年9月

std::optional

  1. 背景
    在编程时,我们经常会遇到可能会返回/传递/使用一个确定类型对象的场景。也就是说,这个对象可能有一个确定类型的值也可能没有任何值。因此,我们需要一种方法来模拟类似指针的语义:指针可以通过 nullptr来表示没有值。解决方法是定义该对象的同时再定义一个附加的 bool类型的值作为标志来表示该对象是否有值。std::optional<>提供了一种类型安全的方式来实现这种对象。
  2. 占用内存大小
    可选对象所需的内存等于内含对象的大小加上一个 bool类型的大小。因此,可选对象一般比内含对象大一个字节(可能还要加上内存对齐的空间开销)。可选对象不需要分配堆内存,并且对齐方式和内含对象相同。
#include <iostream>
#include <optional>

// 定义一个没有默认构造函数的类
class MyClass {
public:
    explicit MyClass(int value) : data(value) {}
    ~MyClass() {}

    int getData() const {
        return data;
    }

private:
    int data;
};

// 输出 std::optional 是否包含值
void check_optional_value(std::optional<MyClass>& opt) {
    if (opt) {
        std::cout << "Value present: " << opt->getData() << std::endl;
    } else {
        std::cout << "No value present." << std::endl;
    }
}

int main() {
    // 创建一个没有值的 std::optional<MyClass>
    std::optional<MyClass> opt1;
    check_optional_value(opt1);

    // 创建一个有值的 std::optional<MyClass>
    std::optional<MyClass> opt2{MyClass(42)};
    check_optional_value(opt2);

    // 尝试通过 emplace 添加值
    opt1.emplace(24);
    check_optional_value(opt1);

    // 尝试通过 operator= 添加值
    opt1 = MyClass(56);
    check_optional_value(opt1);

    return 0;
}

输出:
Size of i: 4 bytes
Size of St8optionalIiE: 8 bytes
Size of 7MyClass: 4 bytes
Size of St8optionalI7MyClassE: 8 bytes

然而,可选对象并不是简单的等价于附加了bool标志的内含对象。例如,在没有值的情况下,将不会调用内含对象的构造函数(通过这种方式,没有默认构造函数的内含类型也可以处于有效的默认状态)。

3.语义
和 std::variant<>、std::any一样,可选对象有值语义。也就是说,拷贝操作会被实现为深拷贝:将创建一个新的独立对象,新对象在自己的内存空间内拥有原对象的标记和内含值(如果有的话)的拷贝。拷贝一个无内含值的 std::optional<>的开销很小,但拷贝有内含值的 std::optional<>的开销约等于拷贝内含值的开销。另外,std::optional<>对象也支持 move语义。

4.应用
(1)std::optional<>模拟了一个可以为空的任意类型的实例。它可以被用作成员、参数、返回值等。
下面的示例程序展示了将 std::optional<>用作返回值的一些功能:

#include <optional>
#include <string>
#include <iostream>

// 如果可能的话把string转换为int:
std::optional<int> asInt(const std::string& s)
{
    try {
        return std::stoi(s);
    }
    catch (...) {
        return std::nullopt;
    }
}

int main()
{
    for (auto s : {"42", "  077", "hello", "0x33"}) {
        // 尝试把s转换为int,并打印结果:
        std::optional<int> oi = asInt(s);
        if (oi.has_value()) {
            std::cout << "convert '" << s << "' to int: " << oi.value() << "\n";
        }
        else {
            std::cout << "can't convert '" << s << "' to int\n";
        }
    }
}

(2) 另一个使用 std::optional<>的例子是传递可选的参数和设置可选的数据成员:

#include <optional>
#include <string>
#include <iostream>

class Name
{
private:
    std::string first;
    std::optional<std::string> middle;
    std::string last;
public:
    Name (std::string f, std::optional<std::string> m, std::string l)
          : first{std::move(f)}, middle{std::move(m)}, last{std::move(l)} {
    }
    friend std::ostream& operator << (std::ostream& strm, const Name& n) {
        strm << n.first << ' ';
        if (n.middle) {
            strm << *n.middle << ' ';
        }
        return strm << n.last;
    }
};

int main()
{
    Name n{"Jim", std::nullopt, "Knopf"};
    std::cout << n << '\n';

    Name m{"Donald", "Ervin", "Knuth"};
    std::cout << m << '\n';
}

5.std::optional<>类型和操作
(1)std::optional<>类型标准库在头文件
中以如下方式定义了 std::optional<>类:
namespace std {
template class optional;
}
另外还定义了下面这些类型和对象:
• std::nullopt_t类型的 std::nullopt,作为可选对象无值时候的“值”。
• 从 std::exception派生的 std::bad_optional_access异常类,当无值时候访问值将会抛出该异常。
可选对象还使用了 头文件中定义的 std::in_place对象(类型是 std::in_place_t)来支持用多个参数初始化可选对象(见下文)。
(2)std::optional<>的操作
表std::optional的操作列出了 std::optional<>的所有操作:

#include <iostream>
#include <optional>
#include <variant>
#include <vector>
#include <set>
#include <map>
#include <string>
#include <cmath>
#include <functional>
#include <cassert>
#include <complex>


// 使用命名空间简化代码
using namespace std::string_literals;

// 示例 1:构造 std::optional
void construct_optional() {
    std::optional<int> o1; // 不含有值
    assert(!o1.has_value());

    std::optional<int> o2(std::nullopt); // 显式表示不含有值
    assert(!o2.has_value());

    std::optional o3{42}; // 推导出 std::optional<int>
    assert(o3.has_value());
    assert(*o3 == 42);

    std::optional o4{"hello"}; // 推导出 std::optional<const char*>
    assert(o4.has_value());
    assert(*o4 == "hello");

    std::optional o5{"hello"s}; // 推导出 std::optional<std::string>
    assert(o5.has_value());
    assert(*o5 == "hello");

    // 用多个参数初始化可选对象
    std::optional<std::complex<double>> o6{std::in_place, 3.0, 4.0};
    assert(o6.has_value());
    assert(o6->real() == 3.0 && o6->imag() == 4.0);

    // 使用 std::make_optional
    auto o13 = std::make_optional(3.0); // std::optional<double>
    assert(o13.has_value());
    assert(*o13 == 3.0);

    auto o14 = std::make_optional("hello"); // std::optional<const char*>
    assert(o14.has_value());
    assert(*o14 == "hello");

    auto o15 = std::make_optional<std::complex<double>>(3.0, 4.0);
    assert(o15.has_value());
    assert(o15->real() == 3.0 && o15->imag() == 4.0);
}

// 示例 2:访问值
void access_optional_value() {
    std::optional<std::pair<int, std::string>> o{std::make_pair(42, "hello")};
    assert(o.has_value());
    assert(o->first == 42);
    assert(o->second == "hello");

    std::optional<std::string> o2{"hello"};
    assert(o2.has_value());
    assert(*o2 == "hello");

    // 当没有值时访问会导致未定义行为
    o2 = std::nullopt;
    assert(!o2.has_value());
    // std::cout << *o2 << std::endl; // 未定义行为
}

// 示例 3:使用 value_or
void use_value_or() {
    std::optional<std::string> o{"hello"};
    std::cout << o.value_or("NO VALUE") << std::endl; // 输出 "hello"

    o = std::nullopt;
    std::cout << o.value_or("NO VALUE") << std::endl; // 输出 "NO VALUE"
}

// 示例 4:比较
void compare_optionals() {
    std::optional<int> o0;
    std::optional<int> o1{42};
    assert(o0 == std::nullopt);
    assert(!(o0 == 42));
    assert(o0 < 42);
    assert(!(o0 > 42));
    assert(o1 == 42);
    assert(o0 < o1);
    assert(!(o0 > o1));

    std::optional<unsigned> uo;
    assert(uo < 0);
    assert(uo < -42);

    std::optional<bool> bo;
    assert(bo < false);

    std::optional<int> o2{42};
    std::optional<double> o3{42.0};
    assert(o2 == 42);
    assert(o3 == 42);
    assert(o2 == o3);
}

// 示例 5:修改值
void modify_optional_value() {
    std::optional<std::complex<double>> o; // 没有值
    std::optional<int> ox{77}; // optional<int>,值为77
    o = 42; // 值变为 complex(42.0, 0.0)
    assert(o.has_value());
    assert(o->real() == 42.0 && o->imag() == 0.0);

    o = std::complex<double>{9.9, 4.4}; // 值变为 complex(9.9, 4.4)
    assert(o.has_value());
    assert(o->real() == 9.9 && o->imag() == 4.4);

    o = ox; // OK,因为 int 转换为 complex<double>
    assert(o.has_value());
    assert(o->real() == 77.0 && o->imag() == 0.0);

    o = std::nullopt; // o 不再有值
    assert(!o.has_value());

    o.emplace(5.5, 7.7); // 值变为 complex(5.5, 7.7)
    assert(o.has_value());
    assert(o->real() == 5.5 && o->imag() == 7.7);

    o.reset(); // o 不再有值
    assert(!o.has_value());

    o = std::complex<double>{88.0, 0.0}; // OK:值变为 complex(88.0, 0.0)
    assert(o.has_value());
    assert(o->real() == 88.0 && o->imag() == 0.0);

    o = std::complex<double>{1.2, 3.4}; // OK:值变为 complex(1.2, 3.4)
    assert(o.has_value());
    assert(o->real() == 1.2 && o->imag() == 3.4);
}

// 示例 6:使用 lambda 初始化 set
void initialize_set_with_lambda() {
    auto sc = [](int x, int y) {
        return std::abs(x) < std::abs(y);
    };

    std::optional<std::set<int, decltype(sc)>> o8{std::in_place,
                                                   std::initializer_list<int>{4, 8, -7, -2, 0, 5},
                                                   sc};
    assert(o8.has_value());
    assert(o8->size() == 6);
}

int main() {
    construct_optional();
    access_optional_value();
    use_value_or();
    compare_optionals();
    modify_optional_value();
    initialize_set_with_lambda();
    return 0;
}

6.注意
(1)value()和 value_or()
value()和 value_or()之间有一个需要考虑的差异:4 value_or()返回值,而 value()返回引用。这意味着如下调用:
std::cout << middle.value_or("");
和:
std::cout << o.value_or("fallback");
都会暗中分配内存,而 value()永远不会。
然而,当在临时对象 (rvalue)上调用 value_or()时,将会移动走内含对象的值并以值返回,而不是调用拷贝函数构造。这是唯一一种能让 value_or()适用于 move-only的类型的方法,因为在左值 (lvalue)上调用的 value_or()的重载版本需要内含对象可以拷贝。
因此,上面例子中效率最高的实现方式是:
std::cout << o ? o‐>c_str() : "fallback";
而不是:
std::cout << o.value_or("fallback");
value_or()是一个能够更清晰地表达意图的接口,但开销可能会更大一点。
(2)bool 类型或原生指针的可选对象
将可选对象用作 bool值时使用比较运算符会有特殊的语义。如果内含类型是 bool或者指针类型的话这可能导致令人迷惑的行为。例如:
std::optional
ob{false}; // 值 为false
if (!ob) ... // 返 回false
if (ob == false) ... // 返 回true
std::optional<int*> op{nullptr};
if (!op) ... // 返 回false
if (op == nullptr) ... // 返 回true

大家好!我是付工。

2012年开始接触Modbus协议,至今已经有10多年了,从开始的懵懂,到后来的顿悟,再到现在的开悟,它始终岿然不动,变化的是我对它的认知和理解。

今天跟大家聊聊关于Modbus协议的那些事。

发展历史

Modbus于1979年诞生,已经历经了40多年。

Modbus诞生在一个特定的时期。1969年,第一台PLC的发明,解决了数字电路代替传统继电器控制的问题,10年之后,Modbus的发明,主要用于解决PLC之间通信的问题。

这些年,它凭借了免费开放、简单易懂等特点,广泛应用在工业自动化领域的各种产品中。

莫迪康当初发明Modbus时,主要针对的是串口设备,即ModbusRTU和ModbusASCII协议,后来施耐德在其基础上发明了针对以太网设备的ModbusTCP。

Modbus协议的诞生与发展,是工业自动化领域技术进步的必然结果,各种工业设备之间的数据交互,必然需要一个高效可靠的协议来支持。

即使1979年莫迪康没有发明Modbus,也许1989年恩迪康也会发明一个Nodbus出来。

协议基础

Modbus协议可以说是所有协议的基础,学习上位机开发自然也离不开它。

我认为,学习Modbus有两个层面,第一个是应用层面,第二个是报文层面。

应用层面可以让我们借助开源通信库很轻松实现设备通信;而报文层面,可以让我们自己写通信库。

可能有人会这么问,既然有开源通信库了,我们是不是就可以不用学Modbus协议报文,直接用现成的通信库?

初期也许可以,但是从长远角度来看,既然选择了上位机这条路,未来必然还会遇到各种各样的协议,而Modbus协议恰恰是一个非常好的学习和练手的机会。

我们把它当作一个跳板,我们学习它,不仅仅是为了使用它,也为理解其他协议奠定一个扎实的基础。所以初学时,一定不要错过这个机会,否则,你会折返跑的。

存储区分类

我喜欢站在协议制定者角度,并结合身边的一些事物来介绍Modbus协议。

首先我们要明确,协议的目的是为了实现数据交互。

么,我们先从【数据】入手,数据必然需要一个载体,自然就有了存储区的概念,这个存储区类似于我们电脑的硬盘。

硬件要分区,存储区也要分类。

至于如何分类,首先我们想到根据数据类型来分,但是不可能每个数据类型分一个,那样太多了,我们将布尔和非布尔分开,因此就有了线圈和寄存器的概念。

在电气回路中,接触器和中继都是靠线圈得电和失电来控制,因此用线圈来表示布尔,而寄存器在PLC中也是用来存储数据的,因此用寄存器来表示非布尔,一个寄存器就表示一个Word。

Modbus更类似于日系和国产PLC,线圈存储区类似于X、Y、M存储区,寄存器存储区类似于D、W、H存储区。

X和Y同样是线圈存储区,X表示的是输入,Y表示的是输出,输入意味着该存储区数据由外部设备接入,是只读的,输出表示输出给外部设备,是可读可写的。

因此,Modbus的线圈和寄存器存储区,还需要按照读写特性,进一步细分,因此形成了Modbus的4个存储区,如下表所示:

序号 读写 存储类型 存储区名称
1 只读 线圈 输入线圈
2 读写 线圈 输出线圈
3 只读 寄存器 输入寄存器
4 读写 寄存器 保持型寄存器


存储区代号

存储区名称是一个完整称呼,实际应用的时候会比较麻烦,因此我们会给这些存储区取一个代号,这个和PLC是一样的,PLC我们只说X区、Y区、D区,只不过PLC使用的是字母作为代号,而Modbus使用的是数字,于是便有了存储区代号表:

存储区名称 存储区代号
输入线圈 1区
输出线圈 0区
输入寄存器 3区
保持型寄存器 4区
这个存储区代号中是没有2区的,这个其实没有理由,也许就是莫迪康单纯的不喜欢2这个数字,这一点和我们国人一样。

Modbus地址

任何一个存储区都是有范围的,比如西门子的M区只有8192个字节,三菱的D区有8000个字,高端系列有18000个字,我们电脑硬盘也是,以前500G很大了,现在动辄1T、2T,都终究有个范围,因此Modbus的存储区也是有范围的,不可能无限大。

Modbus协议是这么规定的,每个存储区最多可能存放65536个线圈或寄存器,这个范围已经很大了。存储区地址是从0开始的,那么对于每个存储区来说,地址范围则从0到65535。

存储区名称 存储区地址
输入线圈 0-65535
输出线圈 0-65535
输入寄存器 0-65535
保持型寄存器 0-65535

这时候会遇到一个问题,比如你跟别人说地址100,别人是不知道是哪个存储区的100,因为每个存储区都有100,那么如何解决这个问题呢?

我们来看下PLC是如何定义的,首先看一个PLC的变量地址,比如D100,这个D100是由D+100组合而成,D是存储区代号,100是地址偏移量,这样的地址模型就直接包含了存储区,这里的D100我们可以理解为绝对地址,而后面的地址偏移量100可以理解为相对地址。

所谓绝对地址,就是通过地址名称,就能明确知道是什么存储区的第几个位置的数据,而相对地址就是地址偏移量,因此绝对地址是唯一的,而相对地址,每个存储区都有。

Modbus仍然遵守这个公式:绝对地址=存储区代号+相对地址。

Modbus和PLC有两个地方不同:

1、PLC的存储区代号是字母,所以可以直接拼接,但是Modbus的存储区代号是数字,如果直接拼接,会导致地址混乱,比如4区的第10个地址,叫410,而0区的410地址也是410,因此必须要保证总长度固定,相对地址始终占5位,不足补0,于是便有了下面的表格,该表格只是当前理解下的表格,并不是最终正确的表格:

2、Modbus协议规定:以保持型寄存器存储区为例,第一个地址不是400000,而是400001,这个是由Modbus规约决定的,其他存储区也是同样的道理。
因此正确的Modbus存储区范围如下表所示:

前面提到过,65536是一个非常大的范围,在实际使用中,我们可能根本用不到这么多地址。于是为了使用方便,还有一种短地址模型,即5位地址模型,前面的称为长地址模型,即6位地址模型,短地址模型存储区范围如下表所示:

直到这里,我们才看到了熟悉的40001,40001这个地址是这样逐步演变出来的。

功能码

我们回到原点,协议的目的是为了实现数据交互。

前面一直在围绕【数据】,下面围绕【交互】说明。

交互即读写。

我们已经有了4个不同的存储区,那么我们对这些存储区的读写,必然会产生很多不同的行为,比如读取输出线圈和写入输出线圈,即为2种不同的行为。我们给这些行为取个代号,即为功能码。

功能码就是Modbus读写行为的代号。

那么会有多少种不同的行为呢?

读取和写入是2种不同的动作,而对象即为4个存储区,排列组合即为2*4=8个,但是输入线圈和输入寄存器是不能写入的,因此8-2=6,如下图所示:

序号 具体行为
1 读取输入线圈
2 读取输出线圈
3 读取输入寄存器
4 读取保持寄存器
5 写入输出线圈
6 写入保持型寄存器

Modbus协议规定:对写入输出线圈和写入保持型寄存器进行细分,分为单个写入和多个连续写入,因此前面的6种行为又变成了8种形成,同时给每个行为取个代号,即形成了我们常说的8大功能码,如下图所示:

功能码 功能说明
0x01 读取输出线圈
0x02 读取输入线圈
0x03 读取保持寄存器
0x04 读取输入寄存器
0x05 写入单个线圈
0x06 写入单个寄存器
0x0F 写入多个线圈
0x10 写入多个寄存器
Modbus协议除了这8种常用的读写功能码,还有一些用于诊断异常的功能码,但是一般很少使用,了解即可。

协议分类

Modbus协议是一个统称,有三个协议家族,分别是ModbusRTU、ModbusASCII和ModbusTCP。

我们常说A和B之间进行Modbus通信,这句话是不严谨的,应当明确指出具体使用哪种通信协议。

一般情况下,ModbusRTU和ModbusASCII用于串行通信,ModbusTCP用于以太网通信,但是这并不是绝对的,因为Modbus协议只是一种应用层的协议,并没有指定物理层,比如,ModbusRTU协议也可以使用在以太网中进行数据传输。

如果准确划分,应该有7种不同的通信方式,我们实际主要使用ModbusRTU和ModbusTCP,其他的使用较少。

报文格式

针对ModbusRTU、ModbusASCII、ModbusTCP这三种不同的协议,在学习时,并不需要学习三次,只要把某一种弄明白,其他两种很容易上手,一般我们以ModbusRTU作为入口,先学习ModbusRTU协议,ModbusASCII了解即可,再学习ModbusTCP协议,下面分别对这三种协议的报文格式进行说明:

1、ModbusRTU的通用报文格式如下:第一部分:从站地址,占1个字节

第二部分:功能码,占1个字节

第三部分:数据部分,占N个字节

第四部分:校验部分,CRC校验,占2个字节

2、ModbusASCII的通用报文格式如下:

第一部分:开始字符(:)

第二部分:从站地址,占2个字节

第三部分:功能码,占2个字节

第四部分:数据部分,占N个字节

第五部分:校验部分,LRC校验,占2个字节

第六部分:结束字符(CR LF)

3、ModbusTCP的通用报文格式如下:

第一部分:事务处理标识符,占2个字节

第二部分:协议标识符,占2个字节

第三部分:长度,占2个字节

第四部分:单元标识符,占1个字节

第五部分:功能码,占1个字节

第六部分:数据部分,占N个字节

具体报文内容通过后面的文章进行阐述。

Modbus学习成本很低,因为协议是公开免费的,而且有很丰富的调试工具,甚至可以在不购买任何硬件的情况下,把Modbus协议学得很透彻。

当然如果有条件,购买一些硬件配合学习,效果更佳。

前言

在项目开发过程中,理解数据结构和算法如同掌握盖房子的秘诀。算法不仅能帮助我们编写高效、优质的代码,还能解决项目中遇到的各种难题。

给大家推荐一个支持C#的开源免费、新手友好的数据结构与算法入门教程:Hello算法。

项目介绍

《Hello Algo》是一本开源免费、新手友好的数据结构与算法入门教程,采用了动画图解的方式,并支持一键运行代码。

该教程覆盖了 Python、Java、C++、C、C#、JS、Go、Swift、Rust、Ruby、Kotlin、TypeScript 和 Dart 等多种编程语言,每种语言都有单独的版本,并且每个版本都提供了 PDF 格式的文档。

下载开源项目后,在仓库的 codes 文件夹中可以找到对应的源代码文件,这些源代码均可一键运行。

项目特点

本项目在打造一本开源免费、新手友好的数据结构与算法入门教程。

全书采用动画图解,内容清晰易懂、学习曲线平滑,引导初学者探索数据结构与算法的知识地图。

源代码可一键运行,帮助读者在练习中提升编程技能,了解算法工作原理和数据结构底层实现。

提倡读者互助学习,欢迎大家在评论区提出问题与分享见解,在交流讨论中共同进步。

项目展示

1、内容导图

2、部分目录

3、源码示例

项目地址

Github

https://github.com/krahets/hello-algo

在线阅读

https://www.hello-algo.com/chapter_hello_algo/

下载PDF

https://github.com/krahets/hello-algo/releases

可以选择C#版本进行下载学习,具体如下图所示:

最后

如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享知识的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。

也可以加入微信公众号
[DotNet技术匠]
社区,与其他热爱技术的同行一起交流心得,共同成长!
优秀是一种习惯,欢迎大家留言学习!

argoworkflow-4-artifacts-archive.png

上一篇我们分析了argo-workflow 中的 artifact,包括 artifact-repository 配置以及 Workflow 中如何使用 artifact。本篇主要分析流水线 GC 以及归档,防止无限占用集群中 etcd 的空间。

1. 概述

因为 ArgoWorkflow 是用 CRD 方式实现的,不需要外部存储服务也可以正常运行:

  • 运行记录
    使用 Workflow CR 对象存储
  • 运行日志
    则存放在 Pod 中,通过 kubectl logs 方式查看
    • 因此需要保证 Pod 不被删除,否则就无法查看了

但是也正因为所有数据都存放在集群中,当数据量大之后
etcd
存储压力会很大,最终影响到集群稳定性

为了解决该问题 ArgoWorkflow 提供了归档功能,将历史数据归档到外部存储,以降低 etcd 的存储压力。

具体实现为:

  • 1)将 Workflow 对象会存储到 Postgres(或 MySQL)
  • 2)将 Pod 对应的日志会存储到 S3,因为日志数据量可能会比较大,因此没有直接存 PostgresQL。

为了提供归档功能,需要依赖两个存储服务:

  • Postgres:外部数据库,用于存储归档后的工作流记录
  • minio:提供 S3 存储,用于存储 Workflow 中生成的 artifact 以及已归档工作流的 Pod 日志

因此,如果不需要存储太多 Workflow 记录及日志查看需求的话,就不需要使用归档功能,定时清理集群中的数据即可。

2.Workflow GC

Argo Workflows 有个工作流执行记录(Workflow)的清理机制,也就是 Garbage Collect(GC)。GC 机制可以避免有太多的执行记录, 防止 Kubernetes 的后端存储 Etcd 过载。

开启

我们可以在 ConfigMap 中配置期望保留的工作执行记录数量,这里支持为不同状态的执行记录设定不同的保留数量。

首先查看 argo-server 启动命令中指定的是哪个 Configmap

# kubectl -n argo get deploy argo-workflows-server -oyaml|grep args -A 5
      - args:
        - server
        - --configmap=argo-workflows-workflow-controller-configmap
        - --auth-mode=server
        - --secure=false
        - --loglevel

可以看到,这里是用的
argo-workflows-workflow-controller-configmap
,那么修改这个即可。

配置如下:

apiVersion: v1
data:
  retentionPolicy: |
    completed: 3
    failed: 3
    errored: 3
kind: ConfigMap
metadata:
  name: argo-workflows-workflow-controller-configmap
  namespace: argo

需要注意的是,这里的清理机制会将多余的 Workflow 资源从 Kubernetes 中删除。如果希望能更多历史记录的话,建议启用并配置好归档功能。

然后重启 argo-workflow-controller 和 argo-server

kubectl -n argo rollout restart deploy argo-workflows-server
kubectl -n argo rollout restart deploy argo-workflows-workflow-controller

测试

运行多个流水线,看下是否会自动清理

for ((i=1; i<=10; i++)); do
cat <<EOF | kubectl create -f -
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
  generateName: hello-world-
spec:
  entrypoint: whalesay
  templates:
  - name: whalesay
    container:
      image: docker/whalesay
      command: [cowsay]
      args: ["hello world $i"]
EOF
done

创建了 10 个 Workflow,看一下运行完成后会不会自动清理掉

[root@lixd-argo archive]# k get wf
NAME                STATUS      AGE   MESSAGE
hello-world-6hgb2   Succeeded   74s
hello-world-6pl5w   Succeeded   37m
hello-world-9fdmv   Running     21s
hello-world-f464p   Running     18s
hello-world-kqwk4   Running     16s
hello-world-kxbtk   Running     18s
hello-world-p88vd   Running     19s
hello-world-q7xbk   Running     22s
hello-world-qvv7d   Succeeded   10m
hello-world-t94pb   Running     23s
hello-world-w79q6   Running     15s
hello-world-wl4vl   Running     23s
hello-world-znw7w   Running     23s

过一会再看

[root@lixd-argo archive]# k get wf
NAME                STATUS      AGE    MESSAGE
hello-world-f464p   Succeeded   102s
hello-world-kqwk4   Succeeded   100s
hello-world-w79q6   Succeeded   99s

可以看到,只保留了 3 条记录,其他的都被清理了,说明 GC 功能 ok。

3. 流水线归档

https://argo-workflows.readthedocs.io/en/stable/workflow-archive/

开启 GC 功能之后,会自动清理 Workflow 以保证 etcd 不被占满,但是也无法查询之前的记录了。

ArgoWorkflow 也提供了流水线归档功能,来解决该问题。

通过将 Workflow 记录到外部 Postgres 数据库来实现持久化,从而满足查询历史记录的需求。

部署 Postgres

首先,简单使用 helm 部署一个 AIO 的Postgres

REGISTRY_NAME=registry-1.docker.io
REPOSITORY_NAME=bitnamicharts
storageClass="local-path"
# postgres 账号的密码
adminPassword="postgresadmin"

helm install pg-aio oci://$REGISTRY_NAME/$REPOSITORY_NAME/postgresql \
--set global.storageClass=$storageClass \
--set global.postgresql.auth.postgresPassword=$adminPassword \
--set global.postgresql.auth.database=argo

配置流水线归档

同样的,在 argo 配置文件中增加 persistence 相关配置即可:

persistence: 
  archive: true
  postgresql:
    host: pg-aio-postgresql.default.svc.cluster.local
    port: 5432
    database: postgres
    tableName: argo_workflows
    userNameSecret:
      name: argo-postgres-config
      key: username
    passwordSecret:
      name: argo-postgres-config
      key: password

argo-workflows-workflow-controller-configmap 完整内容如下:

apiVersion: v1
data:
  retentionPolicy: |
    completed: 3
    failed: 3
    errored: 3
  persistence: |
    archive: true
    archiveTTL: 180d
    postgresql:
      host: pg-aio-postgresql.default.svc.cluster.local
      port: 5432
      database: argo
      tableName: argo_workflows
      userNameSecret:
        name: argo-postgres-config
        key: username
      passwordSecret:
        name: argo-postgres-config
        key: password
kind: ConfigMap
metadata:
  name: argo-workflows-workflow-controller-configmap
  namespace: argo

然后还要创建一个 secret

kubectl create secret generic argo-postgres-config -n argo --from-literal=password=postgresadmin --from-literal=username=postgres

可能还需要给 rbac,否则 Controller 无法查询 secret

kubectl create clusterrolebinding argo-workflow-controller-admin --clusterrole=admin --serviceaccount=argo:argo-workflows-workflow-controller

然后重启 argo-workflow-controller 和 argo-server

kubectl -n argo rollout restart deploy argo-workflows-server
kubectl -n argo rollout restart deploy argo-workflows-workflow-controller

在启用存档的情况下启动工作流控制器时,将在数据库中创建以下表:

  • argo_workflows
  • argo_archived_workflows
  • argo_archived_workflows_labels
  • schema_history

归档记录 GC

配置文件中的
archiveTTL
用于指定压缩到 Postgres 中的 Workflow 记录存活时间,argo Controller 会根据该配置自动删除到期的记录,若不指定该值则不会删除。

具体如下:

func (r *workflowArchive) DeleteExpiredWorkflows(ttl time.Duration) error {
	rs, err := r.session.SQL().
		DeleteFrom(archiveTableName).
		Where(r.clusterManagedNamespaceAndInstanceID()).
		And(fmt.Sprintf("finishedat < current_timestamp - interval '%d' second", int(ttl.Seconds()))).
		Exec()
	if err != nil {
		return err
	}
	rowsAffected, err := rs.RowsAffected()
	if err != nil {
		return err
	}
	log.WithFields(log.Fields{"rowsAffected": rowsAffected}).Info("Deleted archived workflows")
	return nil
}

不过删除任务默认每天执行一次,因此就算配置为 1m 分钟也不会立即删除。

func (wfc *WorkflowController) archivedWorkflowGarbageCollector(stopCh <-chan struct{}) {
	defer runtimeutil.HandleCrash(runtimeutil.PanicHandlers...)

	periodicity := env.LookupEnvDurationOr("ARCHIVED_WORKFLOW_GC_PERIOD", 24*time.Hour)
	if wfc.Config.Persistence == nil {
		log.Info("Persistence disabled - so archived workflow GC disabled - you must restart the controller if you enable this")
		return
	}
	if !wfc.Config.Persistence.Archive {
		log.Info("Archive disabled - so archived workflow GC disabled - you must restart the controller if you enable this")
		return
	}
	ttl := wfc.Config.Persistence.ArchiveTTL
	if ttl == config.TTL(0) {
		log.Info("Archived workflows TTL zero - so archived workflow GC disabled - you must restart the controller if you enable this")
		return
	}
	log.WithFields(log.Fields{"ttl": ttl, "periodicity": periodicity}).Info("Performing archived workflow GC")
	ticker := time.NewTicker(periodicity)
	defer ticker.Stop()
	for {
		select {
		case <-stopCh:
			return
		case <-ticker.C:
			log.Info("Performing archived workflow GC")
			err := wfc.wfArchive.DeleteExpiredWorkflows(time.Duration(ttl))
			if err != nil {
				log.WithField("err", err).Error("Failed to delete archived workflows")
			}
		}
	}
}

需要设置环境变量
ARCHIVED_WORKFLOW_GC_PERIOD
来调整该值,修改 argo-workflows-workflow-controller 增加 env,就像这样:

        env:
        - name: ARCHIVED_WORKFLOW_GC_PERIOD
          value: 1m

测试

接下来创建 Workflow 看下是否测试

for ((i=1; i<=10; i++)); do
cat <<EOF | kubectl create -f -
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
  generateName: hello-world-
spec:
  entrypoint: whalesay
  templates:
  - name: whalesay
    container:
      image: docker/whalesay
      command: [cowsay]
      args: ["hello world $i"]
EOF
done

查看下是 postgres 中是否生成归档记录

export POSTGRES_PASSWORD=postgresadmin

kubectl run postgresql-dev-client --rm --tty -i --restart='Never' --namespace default --image docker.io/bitnami/postgresql:14.1.0-debian-10-r80 --env="PGPASSWORD=$POSTGRES_PASSWORD" --command -- psql --host pg-aio-postgresql -U postgres -d argo -p 5432

按 Enter 进入 Pod 后直接查询即可

# 查询表
argo-# \dt
                     List of relations
 Schema |              Name              | Type  |  Owner
--------+--------------------------------+-------+----------
 public | argo_archived_workflows        | table | postgres
 public | argo_archived_workflows_labels | table | postgres
 public | argo_workflows                 | table | postgres
 public | schema_history                 | table | postgres
(4 rows)

# 查询记录
argo=# select name,phase from argo_archived_workflows;
       name        |   phase
-------------------+-----------
 hello-world-s8v4f | Succeeded
 hello-world-6pl5w | Succeeded
 hello-world-qvv7d | Succeeded
 hello-world-vgjqr | Succeeded
 hello-world-g2s8f | Succeeded
 hello-world-jghdm | Succeeded
 hello-world-fxtvk | Succeeded
 hello-world-tlv9k | Succeeded
 hello-world-bxcg2 | Succeeded
 hello-world-f6mdw | Succeeded
 hello-world-dmvj6 | Succeeded
 hello-world-btknm | Succeeded
(12 rows)

# \q 退出
argo=# \q

可以看到,Postgres 中已经存储好了归档的 Workflow,这样需要查询历史记录时到 Postgres 查询即可。

将 archiveTTL 修改为 1 分钟,然后重启 argo,等待 1 至2 分钟后,再次查看

argo=#  select name,phase from argo_archived_workflows;
 name | phase
------+-------
(0 rows)

argo=#

可以看到,所有记录都因为 TTL 被清理了,这样也能保证外部 Postgres 中的数据不会越累积越多。

4. Pod 日志归档

https://argo-workflows.readthedocs.io/en/stable/configure-archive-logs/

流水线归档实现了流水线持久化,即使把集群中的 Workflow 对象删除了,也可以从 Postgres 中查询到记录以及状态等信息。

但是流水线执行的日志却分散在对应 Pod 中的,如果 Pod 被删除了,日志就无法查看了,因此我们还需要做日志归档。

配置 Pod 归档

全局配置

在 argo 配置文件中开启 Pod 日志归档并配置好 S3 信息。

具体配置如下:

和第三篇配置的 artifact 一样,只是多了一个
archiveLogs: true

artifactRepository:
  archiveLogs: true
  s3:
    endpoint: minio.default.svc:9000
    bucket: argo
    insecure: true
    accessKeySecret:
      name: my-s3-secret
      key: accessKey
    secretKeySecret:
      name: my-s3-secret
      key: secretKey

完整配置如下:

apiVersion: v1
data:
  retentionPolicy: |
    completed: 3
    failed: 3
    errored: 3
  persistence: |
    archive: true
    postgresql:
      host: pg-aio-postgresql.default.svc.cluster.local
      port: 5432
      database: argo
      tableName: argo_workflows
      userNameSecret:
        name: argo-postgres-config
        key: username
      passwordSecret:
        name: argo-postgres-config
        key: password
  artifactRepository: |
    archiveLogs: true
    s3:
      endpoint: minio.default.svc:9000
      bucket: argo
      insecure: true
      accessKeySecret:
        name: my-s3-secret
        key: accessKey
      secretKeySecret:
        name: my-s3-secret
        key: secretKey
kind: ConfigMap
metadata:
  name: argo-workflows-workflow-controller-configmap
  namespace: argo

注意:根据第三篇分析 artifact,argo 中关于 artifactRepository 的信息包括三种配置方式:

  • 1)全局配置
  • 2)命名空间默认配置
  • 3)Workflow 中指定配置

这里是用的全局配置方式,如果 Namespace 级别或者 Workflow 级别也配置了 artifactRepository 并指定了不开启日志归档,那么也不会归档的。

然后重启 argo

kubectl -n argo rollout restart deploy argo-workflows-server
kubectl -n argo rollout restart deploy argo-workflows-workflow-controller

在 Workflow & template 中配置

配置整个工作流都需要归档

apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
  generateName: archive-location-
spec:
  archiveLogs: true
  entrypoint: whalesay
  templates:
  - name: whalesay
    container:
      image: docker/whalesay:latest
      command: [cowsay]
      args: ["hello world"]

配置工作流中的某一个 template 需要归档。

apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
  generateName: archive-location-
spec:
  entrypoint: whalesay
  templates:
  - name: whalesay
    container:
      image: docker/whalesay:latest
      command: [cowsay]
      args: ["hello world"]
    archiveLocation:
      archiveLogs: true

小结

3 个地方都可以配置是否归档,就还挺麻烦的,根据官方文档,各个配置优先级如下:

workflow-controller config (on) > workflow spec (on/off) > template (on/off)

Controller Config Map Workflow Spec Template are we archiving logs?
true true true true
true true false true
true false true true
true false false true
false true true true
false true false false
false false true true
false false false false

对应的代码实现:

// IsArchiveLogs determines if container should archive logs
// priorities: controller(on) > template > workflow > controller(off)
func (woc *wfOperationCtx) IsArchiveLogs(tmpl *wfv1.Template) bool {
	archiveLogs := woc.artifactRepository.IsArchiveLogs()
	if !archiveLogs {
		if woc.execWf.Spec.ArchiveLogs != nil {
			archiveLogs = *woc.execWf.Spec.ArchiveLogs
		}
		if tmpl.ArchiveLocation != nil && tmpl.ArchiveLocation.ArchiveLogs != nil {
			archiveLogs = *tmpl.ArchiveLocation.ArchiveLogs
		}
	}
	return archiveLogs
}

建议配置全局的就行了。

测试

接下来创建 Workflow 看下是否测试

cat <<EOF | kubectl create -f -
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
  generateName: hello-world-
spec:
  entrypoint: whalesay
  templates:
  - name: whalesay
    container:
      image: docker/whalesay
      command: [cowsay]
      args: ["hello world"]
EOF

等待 Workflow 运行完成

# k get po
NAME                     READY   STATUS      RESTARTS   AGE
hello-world-6pl5w        0/2     Completed   0          53s
# k get wf
NAME                STATUS      AGE   MESSAGE
hello-world-6pl5w   Succeeded   55s

到 S3 查看是否有日志归档文件

argo-archive-log.png

可以看到,在指定 bucket 里已经存储了一个日志文件,以
$bucket/$workflowName/$stepName
格式命名。

正常一个 Workflow 都会有多个 Step,每一个 step 分一个目录存储

内容就是 Pod 日志,具体如下:

 _____________ 
< hello world >
 ------------- 
    \
     \
      \     
                    ##        .            
              ## ## ##       ==            
           ## ## ## ##      ===            
       /""""""""""""""""___/ ===        
  ~~~ {~~ ~~~~ ~~~ ~~~~ ~~ ~ /  ===- ~~~   
       \______ o          __/            
        \    \        __/             
          \____\______/   

5. 小结


【ArgoWorkflow 系列】
持续更新中,搜索公众号【
探索云原生
】订阅,阅读更多文章。


总结一下,本文主要分析了以下 3 部分内容:

  • 1)开启 GC,自动清理运行完成的 Workflow 记录,避免占用 etcd 空间
  • 2)开启流水线归档,将 Workflow 记录存储到外部 Postgres,便于查询历史记录
  • 3)开启 Pod 日志归档,将流水线每一步 Pod 日志记录到 S3,便于查询,否则 Pod 删除就无法查询了

生产使用,一般都建议开启相关的清理和归档功能,如果全存储到 etcd,难免会影响到集群性能和稳定性。

现实世界的数据通常表现为长尾分布,常跨越多个类别。这种复杂性突显了内容理解的挑战,特别是在需要长尾多标签图像分类(
LTMLC
)的场景中。在这些情况下,不平衡的数据分布和多物体识别构成了重大障碍。为了解决这个问题,论文提出了一种新颖且有效的
LTMLC
方法,称为类别提示精炼特征学习(
CPRFL
)。该方法从预训练的
CLIP
嵌入初始化类别提示,通过与视觉特征的交互解耦类别特定的视觉表示,从而促进了头部类和尾部类之间的语义关联建立。为了减轻视觉-语义领域的偏差,论文设计了一种渐进式双路径反向传播机制,通过逐步将上下文相关的视觉信息纳入提示来精炼提示。同时,精炼过程在精炼提示的指导下促进了类别特定视觉表示的渐进纯化。此外,考虑到负样本与正样本的不平衡,采用了非对称损失作为优化目标,以抑制所有类别中的负样本,并可能提升头部到尾部的识别性能。

来源:晓飞的算法工程笔记 公众号,转载请注明出处

论文: Category-Prompt Refined Feature Learning for Long-Tailed Multi-Label Image Classification

Introduction


随着深度网络的快速发展,近年来计算机视觉领域取得了显著的进展,尤其是在图像分类任务中。这一进展在很大程度上依赖于许多主流的平衡基准(例如
CIFAR

ImageNet ILSVRC

MS COCO
),这些基准具有两个关键特征:
1
)它们提供了在所有类别之间相对平衡且数量充足的样本,
2
)每个样本仅属于一个类别。然而,在实际应用中,不同类别的分布往往呈现长尾分布模式,深度网络往往在尾部类别上表现不佳。同时,与经典的单标签分类不同,实际场景中图像通常与多个标签相关联,这增加了任务的复杂性和挑战。为了应对这些问题,越来越多的研究集中在长尾多标签图像分类(
LTMLC
)问题上。

由于尾部类别的样本相对稀少,解决长尾多标签图像分类(
LTMLC
)问题的主流方法主要集中在通过采用各种策略来解决头部与尾部的不平衡问题,例如对每个类别的样本数量进行重采样、为不同类别重新加权损失、以及解耦表示学习和分类头的学习。尽管这些方法做出了重要贡献,但它们通常忽略了两个关键方面。首先,在长尾学习中,考虑头部和尾部类别之间的语义相关性至关重要。利用这种相关性可以在头部类别的支持下显著提高尾部类别的性能。其次,实际世界中的图像通常包含多种对象、场景或属性,这增加了分类任务的复杂性。上述方法通常从全局角度考虑提取图像的视觉表示。然而,这种全局视觉表示包含了来自多个对象的混合特征,这阻碍了对每个类别的有效特征分类。因此,如何在长尾数据分布中探索类别之间的语义相关性,并提取局部类别特定特征,仍然是一个重要的研究领域。

最近,视觉-语言预训练(
VLP
)模型已成功适应于各种下游视觉任务。例如,
CLIP
在数十亿对图像-文本样本上进行预训练,其文本编码器包含了来自自然语言处理(
NLP
)语料库的丰富语言知识。文本编码器在编码文本模态中的语义上下文表示方面展示了巨大的潜力。因此,可以利用
CLIP
的文本嵌入表示来编码头部和尾部类别之间的语义相关性。此外,在许多研究中,
CLIP
的文本嵌入已成功作为语义提示,用于将局部类别特定的视觉表示与全局混合特征解耦。

为了应对长尾多标签分类(
LTMLC
)固有的挑战,论文提出了一种新颖且有效的方法,称为类别提示精炼特征学习(
Category-Prompt Refined Feature Learning

CPRFL
)。
CPRFL
利用
CLIP
的文本编码器的强大的语义表示能力提取类别语义,从而建立头部和尾部类别之间的语义相关性。随后,提取的类别语义用于初始化所有类别的提示,这些提示与视觉特征交互,以辨别与每个类别相关的上下文视觉信息。

这种视觉-语义交互可以有效地将类别特定的视觉表示从输入样本中解耦,但这些初始提示缺乏视觉上下文信息,导致在信息交互过程中语义和视觉领域之间存在显著的数据偏差。本质上,初始提示可能不够精准,从而影响类别特定视觉表示的质量。为了解决这个问题,论文引入了一种渐进式双路径反向传播(
progressive Dual-Path Back-Propagation
)机制来迭代精炼提示。该机制逐步将与上下文相关的视觉信息积累到提示中。同时,在精炼提示的指导下,类别特定的视觉表示得到净化,从而提高其相关性和准确性。

最后,为了进一步解决多类别中固有的负样本与正样本不平衡问题,论文引入了在这种情况下常用的重新加权(
Re-Weighting
,
RW
)策略。具体来说,采用了非对称损失(
Asymmetric Loss

ASL
)作为优化目标,有效抑制了所有类别中的负样本,并可能改善
LTMLC
任务中头部与尾部类别的性能。

论文贡献总结如下:

  1. 提出了一种新颖的提示学习方法,称为类别提示精炼特征学习(
    CPRFL
    ),用于长尾多标签图像分类(
    LTMLC
    )。
    CPRFL
    利用
    CLIP
    的文本编码器提取类别语义,充分发挥其强大的语义表示能力,促进头部和尾部类别之间的语义关联的建立。提取的类别语义作为类别提示,用于实现类别特定视觉表示的解耦。这是首次利用类别语义关联来缓解
    LTMLC
    中的头尾不平衡问题,提供了一种针对数据特征量身定制的开创性解决方案。

  2. 设计了一种渐进式双路径反向传播机制,旨在通过在视觉-语义交互过程中逐步将与上下文相关的视觉信息融入提示中,从而精炼类别提示。通过采用一系列双路径梯度反向传播,有效地抵消了初始提示带来的视觉-语义领域偏差。同时,精炼过程促进了类别特定视觉表示的逐步净化。

  3. 在两个
    LTMLC
    基准测试上进行了实验,包括公开可用的数据集
    COCO-LT

    VOC-LT
    。大量实验不仅验证了方法的有效性,还突显了其相较于最近先进方法的显著优越性。

Methods


Overview

CPRFL
方法包括两个子网络,即提示初始化(
PI
)网络和视觉-语义交互(
VSI
)网络。首先,利用预训练的
CLIP
的文本嵌入来初始化
PI
网络中的类别提示,利用类别语义编码不同类别之间的语义关联。随后,这些初始化的提示通过
VSI
网络中的
Transformer
编码器与提取的视觉特征进行交互。这个交互过程有助于解耦类别特定的视觉表示,使框架能够辨别与每个类别相关的上下文相关的视觉信息。最后,在类别层面计算类别特定特征与其对应提示之间的相似性,以获得每个类别的预测概率。为了减轻视觉-语义领域偏差,采用了一个逐步的双路径反向传播机制,由类别提示学习引导,以细化提示并在训练迭代中逐步净化类别特定的视觉表示。为进一步解决负样本与正样本的不平衡问题,采用了重加权策略(即非对称损失(
ASL
)),这有助于抑制所有类别中的负样本。

  • Feature Extraction

给定来自数据集
\(D\)
的输入图像
\(x\)
,首先利用一个主干网络提取局部图像特征
\(f_{loc}^x \in \mathbb{R}^{h \times w \times d_0}\)
,其中
\(d_0,h,w\)
分别表示通道数、高度和宽度。论文采用了如
ResNet-101
的卷积网络,并通过去除最后的池化层来获取局部特征。之后,添加一个线性层
\(\varphi\)
,将特征从维度
\(d_0\)
映射到维度
\(d\)
,以便将其投影到一个视觉-语义联合空间,从而匹配类别提示的维度:

\[\begin{equation}
\mathcal{F} = \varphi(f_{loc}^x) = \{f_1,f_2,...,f_v\} \in \mathbb{R}^{v \times d}, v = h \times w.
\label{eq:1}
\end{equation}
\]

利用局部特征,我们在它们与初始类别提示之间进行视觉-语义信息交互,以辨别类别特定的视觉信息。

  • Semantic Extraction

形式上,预训练的
CLIP
包括一个图像编码器
\(f(\bullet)\)
和一个文本编码器
\(g(\bullet)\)
。为了论文的目的,仅利用文本编码器来提取类别语义。具体来说,采用一个经典的预定义模板 "
a photo of a
[
CLASS
]" 作为文本编码器的输入文本。然后,文本编码器将输入文本(类别
\(i\)

\(i=1,...,c\)
)映射到文本嵌入
\(\mathcal{W} = g(i) =\{w_1,w_2,...,w_c\}\in \mathbb{R}^{c \times m}\)
,其中
\(c\)
表示类别数,
\(m\)
表示嵌入的维度长度。提取的文本嵌入作为初始化类别提示的类别语义。

Category-Prompt Initialization

为了弥合语义领域和视觉领域之间的差距,近期的研究尝试使用线性层将语义词嵌入投影到视觉-语义联合空间。论文选择了非线性结构来处理来自预训练
CLIP
文本嵌入的类别语义,而不是直接使用线性层进行投影。这种方法能够实现从语义空间到视觉-语义联合空间的更复杂的投影。

具体来说,论文设计了一个提示初始化(
PI
)网络,该网络由两个全连接层和一个非线性激活函数组成。通过
PI
网络执行的非线性变换,将预训练
CLIP
的文本嵌入
\(\mathcal{W}\)
映射到初始类别提示
\(\mathcal{P} = \{p_1,p_2,...,p_c\}\in \mathbb{R}^{c \times d}\)

\[\begin{equation}
\mathcal{P} = GELU(\mathcal{W}W_1+b_1)W_2+b_2,
\label{eq:2}
\end{equation}
\]

其中,
\(W_1\)

\(W_2\)

\(b_1\)

\(b_2\)
分别表示两个线性层的权重矩阵和偏置向量,而
\(GELU\)
表示非线性激活函数。这里,
\(W_1 \in \mathbb{R}^{m \times t}\)

\(W_2 \in \mathbb{R}^{t \times d}\)

\(t = \tau \times d\)

\(\tau\)
是控制隐藏层维度的扩展系数。通常情况下,
\(\tau\)
被设置为
0.5

PI
网络在从预训练
CLIP
的文本编码器中提取类别语义方面发挥了至关重要的作用,利用其强大的语义表示能力,在不依赖真实标签的情况下建立不同类别之间的语义关联。通过用类别语义初始化类别提示,
PI
网络促进了从语义空间到视觉-语义联合空间的投影。此外,
PI
网络的非线性设计增强了提取类别提示的视觉-语义交互能力,从而改善了后续的视觉-语义信息交互。

Visual-Semantic Information Interaction

随着
Transformer
在计算机视觉领域的广泛应用,近期的研究展示了典型注意力机制在增强视觉-语义跨模态特征交互方面的能力,这激励论文设计了一个视觉-语义交互(
VSI
)网络。该网络包含一个
Transformer
编码器,以初始类别提示和视觉特征作为输入。
Transformer
编码器执行视觉-语义信息交互,以辨别与每个类别相关的上下文特定视觉信息。这个交互过程有效地解耦了类别特定的视觉表示,从而促进了每个类别的更好特征分类。

为了促进类别提示与视觉特征之间的视觉-语义信息交互,将初始类别提示
\(\mathcal{P} \in \mathbb{R}^{c \times d}\)
与视觉特征
\(\mathcal{F} \in \mathbb{R}^{v \times d}\)
进行连接,形成一个组合嵌入集
\(Z = (\mathcal{F},\mathcal{P}) \in \mathbb{R}^{(v+c) \times d}\)
,输入到
VSI
网络中进行视觉-语义信息交互。在
VSI
网络中,每个嵌入
\(z_i \in Z\)
通过
Transformer
编码器固有的多头自注意力机制进行计算和更新。值得注意的是,仅关注更新类别提示
\(\mathcal{P}\)
,因为这些提示代表了类别特定视觉表示的解耦部分。注意力权重
\(\alpha_{ij}^p\)
和随后的更新过程计算如下:

\[\begin{equation}
\alpha_{ij}^p = softmax\left((W_qp_i)^T(W_kz_i)/\sqrt{d}\right),
\label{eq:3}
\end{equation}
\]

\[\begin{equation}
\bar{p}_i = \sum_{j=1}(\alpha_{ij}^pW_vz_j),
\label{eq:4}
\end{equation}
\]

\[\begin{equation}
p_i' = GELU(\bar{p}_iW_r+b_3)W_o+b_4,
\end{equation}
\]

其中,
\(W_q, W_k, W_v\)
分别是查询、键和值的权重矩阵,
\(W_r, W_o\)
是变换矩阵,
\(b_3, b_4\)
是偏置向量。为了简化
VSI
网络的复杂度,选择了单层
Transformer
编码器而不是堆叠层。
VSI
网络的输出结果和类别特定的视觉特征分别记作
\(Z' = \{f_1', f_2', ..., f_v', p_1', p_2', ..., p_c'\}\)

\(\mathcal{P}' = \{p_1', p_2', ..., p_c'\}\)
。在自注意力机制下,每个类别提示嵌入综合考虑了其对所有局部视觉特征和其他类别提示嵌入的注意力。这种综合注意力机制有效地辨别了样本中的上下文相关视觉信息,从而实现了类别特定视觉表示的解耦。

Category-Prompt Refined Feature Learning

在通过
VSI
网络实现视觉特征与初始提示的交互后,得到的输出
\(\mathcal{P}'\)
作为分类的类别特定特征。在传统的基于
Transformer
的方法中,从
Transformer
获得的具体输出特征通常通过线性层投影到标签空间,用于最终分类。与这些方法不同,将类别提示
\(\mathcal{P}\)
作为分类器,并计算类别特定特征与类别提示之间的相似性,以在特征空间内进行分类。类别
\(i\)
的分类概率
\(s_i\)
可以通过以下计算:

\[\begin{equation}
s_i = sigmoid(p_i' \cdot p_i).
\label{eq:6}
\end{equation}
\]

在多标签设置中,由于数据特性的独特性,需要计算每个类别的类别特定特征向量与相应提示向量之间的点积相似度来确定概率(
softmax
一下),这种计算方法体现了绝对相似性。而论文偏离了传统的相似性模式,而是使用类别特定特征向量与所有提示向量之间的相对测量。这种做法的原因在于减少了计算冗余,因为计算每个类别的特征向量与无关类别提示之间的相似度是不必要的。

初始提示缺乏关键的视觉上下文信息,导致在信息交互过程中语义域与视觉域之间存在显著的数据偏差。这种差异导致初始提示不准确,从而影响类别特定视觉表示的质量。为了解决这个问题,论文引入了一种由类别提示学习引导的渐进式双路径反向传播机制。该机制在模型训练过程中涉及两个梯度优化路径(如图
2a
所示):一条通过
VSI
网络,另一条直接到
PI
网络。前者路径还优化
VSI
网络,以增强其视觉语义信息交互的能力。通过采用一系列双路径梯度反向传播,提示在训练迭代中逐渐得到优化,从而逐步积累与上下文相关的视觉信息。同时,优化后的提示指导生成更准确的类别特定视觉表示,从而实现类别特定特征的渐进净化。论文将这一整个过程称为“提示精炼特征学习”,反复进行直到收敛,如图
2b
所示。

Optimization

为了进一步解决多类别中固有的负样本与正样本不平衡问题,论文整合了在这种情况下常用的重新加权(
Re-Weighting
,
RW
)策略。具体而言,采用不对称损失(
Asymmetric Loss
,
ASL
)作为优化目标。
ASL
是一种焦点损失(
focal loss
)的变体,对正样本和负样本使用不同的
\(\gamma\)
值。给定输入图像
\(x_i\)
,模型预测其最终类别概率
\(S_i = \{s_1^i,s_2^i,...,s_c^i\}\)
,其真实标签为
\(Y_i = \{y_1^i,y_2^i,...,y_c^i\}\)

使用
ASL
训练整个框架,如下所示:

\[\begin{equation}
\mathcal{L}_{cls} = \mathcal{L}_{ASL} = \sum_{x_i \in X}\sum_{j=1}^c
\begin{cases}
(1-s_j^i)^{\gamma^{+}}log(s_j^i),&s_j^i=1,\\
(\tilde{s}_j^i)^{\gamma^{-}}log(1-\tilde{s}_j^i),&s_j^i=0,\\
\end{cases}
\label{eq:7}
\end{equation}
\]

其中,
\(c\)
是类别的数量。
\(\tilde{s}_j^i\)

ASL
中的硬阈值,表示为
\(\tilde{s}_j^i = \max(s_j^i - \mu, 0)\)

\(\mu\)
是一个用于过滤低置信度负样本的阈值。默认情况下,设置
\(\gamma^{+} = 0\)

\(\gamma^{-} = 4\)
。在论文的框架中,
ASL
有效地抑制了所有类别中的负样本,可能改善了
LTMLC
任务中的头尾类别性能。

Experiments




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

work-life balance.