2024年7月

在上一篇
程序员失业日记1:工作五年,交接半天
收到很多回复,其中最多的就是
公司能赔 n+1 已经很良心了。
不知道自己的该庆幸,还是该失落,作为一个普通打工人,遇到什么样的公司大部分还是看运气,少部分靠自己一点点的努力。

从公司离职后,开始几天是非常开心的,不用工作,心情很放空,每天都睡到自然醒。每天都可以随意的安排生活,不急不赶的爬山,或者每天只做一点很简单的事情,但是没有时间的焦虑。

后面就开始迷茫了,这种迷茫是有工作、或者已经找到下家无法感同身受的。大部分还是因为少了收入,没有收入之后,还是支付各种生活开销。失业后的未来了不确定性:是否还能找到理想的工作、找工作还需要多少时间。前几年市场对于程序员岗位要求多,即使被裁了,也能很快的找到工作,但今年的形势就比往年更差一点,找工作也没有那么容易了。对未来开始迷茫。

打工很舒服

可能听这话的时候,很多小伙伴就会说,打工怎么可能会舒服呢?且听我慢慢说。打工确实是很辛苦,很累,要经常加班加点的干活。
累的要死,挣钱少的可怜

按部就班

这里谈论的是底层或者中层的打工人,工资不高。每天做的事情比较固定,只需要按部就班的上班,工作都被拆解成一个一个比较细小的工作,每个人只需要完成自己那一小部分即可,不需要全盘考虑整个任务是否安排合理,如果失败了也不需要考虑如何承担责任。**大部分程序员工作本质上和工厂流水线差不多,重复、可替代性强,可能也就工作环境比工厂好一点*。

一个企业需要考虑风险,比如做了半年的事情,因为某些原因,项目失败了,就没有收入了。而打工人每个月还能可以有到手的工资。从企业的角度看,收入高,承担的风险也就越高。

裁员打破舒服

突然一下子的裁员,没有收入,很多人都没有足够的储蓄,就一直开始疯狂的投简历,有个甚至找了半年都没找到工作。之前可能收入比较高,因为年龄大了,公司裁员,一下子从高收入变成零收入,有的还有房贷、车贷每个月要还贷款。

除了找工作之外,很多人尝试过副业,写文章、拍视频、小红书之类的,一试之后发现很多不确定的因素。

  • 写了很多没有流量。
  • 如何选题
  • 如何吸引更多的粉丝,关注
  • 如何变现

这上面可能有很多打着卖课的名义教你,大部分还是没有效果。
其实这就是一种企业模式,做事都要靠自己去摸索,做了很多事情可能也会失败。

打工很安逸

大部分人是不会主动跳槽,只要不被裁,都会一直待在公司。领导的忽悠经常忽悠员工:今年业绩好就会升职加薪,寄希望公司会因为多工作几年,给自己升值加薪。工作几年后,即使知道领导是画饼,但在公司待了几年了,环境也熟悉、同事也比较熟悉,对公司产生了不应该有的感情。偶尔也有一点加薪,还是觉得待在公司比较安逸。一直在公司做事,没有职业规划,也没要多余的工作准备,

裁员打破安逸

本来还想着在公司加薪的美梦时,忽然被裁员,很多都没啥反应。
曾经以为你的工作很重要,开除的时候,交接都可能不需要。
被裁之后,发现自己一直都是做公司的事情,做的工作没有总结,没有学习。再去面试的时候,基本的都是拒掉。

大部分工作也就是实现功能,这类的工作替代性比较大,给公司打工,能力确实是有提高,每天做的事情很多,可是大部分的时间都用来做事,给公司加成。很少总结自己的做事方法,解决问题的思路。等下次有类似的问题,可能有一个小点的会遇到同样的问题。就能很快的引用一个问题,但是如果比较大的问题,就比较难了。或者更多的时候就是忙自己的工作,没有时间思考。

如何应付裁员

之前很火的一句话
世界是个草台班子”
,其实公司也是一个草台班子,个人也是一个草台班子。对于个人来说,还是要多去思考如何更好的工作,如何将工作经验转成自己的个人经验,这都是很重要的能力。

降低开支

裁员以后大部分迷茫是因为对未来的不确定,没有收入之后,首先就要节省开支,除了必需品之外,其他的尽量少买。我被裁以后不是很紧张,是因为我平时就有储蓄的习惯,每个月都会存一笔存款。有了存款再加上赔偿,心里才不慌。现在经济不太乐观的时候,还是需要多节省开支,尽量的降低负债。

工作勤思考,多总结

很多人喜欢工作摸鱼,只要自己多摸鱼,那自己挣的就更多。这句话一半是比较在理,但另外一半也不对。公司裁员,一般都是先裁这类员工。

无论是升职还是跳槽,大部分时候还是看工作能力。在公司想要提高能力,就需要多思考,多总结。很多人上班就不会多思考,工作完成就行,没有总结就没有进步,每天就是重复昨天的工作。多做一点总结,分析,形成一套自己的解题思路。即使企业裁员,这样做的

多输出

如果单纯的思考,不做输出,也很难坚持下去。通过输出,总结和反馈自己的想法,先形成一个输出和转成成果,积极的分享出去,而不是闭门造车。

现在是一个互联网时代,通过平台一方面可以扩大自己的影响力,比如通过写技术文章,给你的面试加分。写各种输出文章之后,就有反馈,自己哪里存在不足和需要提高的地方。通过互联网平台也可以尝试开始副业。

对象的生命周期是c++中非常重要的概念,它直接决定了你的程序是否正确以及是否存在安全问题。

今天要说的临时变量导致的生命周期问题是非常常见的,很多时候没有一定经验甚至没法识别出来。光是我自己写、review、回答别人的问题就犯了或者看到了许许多多这类问题,所以我想有必要做个简单的总结,自己备忘的同时也尽量帮其他开发者尤其是别的语言转c++的人少踩些坑。

问题主要分为三类,每类我都会给出典型例子,最后会给出解决办法。不过在深入讨论每一类问题之前,先让我们复习点必要的基础知识。

基础回顾

基础回顾少不了,否则看c++的文章容易变成看天书。

但也别紧张,都叫“基础”了那肯定是些简单的偏常识的东西,不难的。

第一个基础是语句和表达式。语句好理解,
for(...){}
是一个语句,
int a = num + 1;
也是一个语句,除了一些特殊的语法结构,语句通常以分号结尾。表达式是什么呢,语句中除了关键字和符号之外的东西都可以算表达式,比如
int a = num + 1
中,
num

1

num + 1
都是表达式。当然单独的表达式也可以构成语句,比如
num;
是语句。

这里就有个概率要回顾了:“完整的表达式”。什么叫完整,粗暴的理解就是同一个语句里的所有子表达式组合起来的那个表达式才叫“完整的表达式”。举个例子
int a = num + 1;

int a = num + 1
才是一个完整的表达式;
str().trimmed().replace(pattern, gettext());

str().trimmed().replace(pattern, gettext())
才是完整的表达式。

这个概念后面会很有用。

第二个要复习的是
const T &
对临时变量生命周期的影响。

一个临时对象(通常是prvalue)可以绑定到
const T &
或者右值引用上。绑定后临时对象的生命周期会一直延长到绑定的引用的生命周期结束的时候。但延长有一个例外:

const int &func()
{
    return 100;
}

这个大家都知道是悬垂引用,但
const T &
不是能延长100这个临时int对象的生命周期吗,这里理论上不应该是和返回值的生命周期一样么,这么会变成悬垂引用?

答案是语法规定的例外,引用绑定延长的生命周期不能跨越作用域。这里显然100是在函数内的作用域,而返回的引用作用域在函数之外,跨越作用域了,所以这时绑定不能延长临时int对象的生命周期,临时对象在函数调用结束后销毁,所以产生了悬垂引用。

另外绑定带来的延长是不能传递的,只有直接绑定到临时对象上才能延长生命,其他情况比如通过另一个引用进行的绑定都没有效果。

复习到此为止,我们来看具体问题。

函数调用中的生命周期问题

先看例子:

const int &value = std::max(v, 100);

这是三类问题中最常见的一类,甚至常见到了各大文档包括cppreference上都专门开了个脚注告诉你这么写是错的。

这个错也很难察觉,我们一步步来。

首先是看
std::max
的函数签名,当然因为实现代码也很简单所以一块看下简化版:

template <typename T>
const T & max(const T &a, const T &b)
{
    return a>b ? a : b;
}

参数用
const T &
有道理,这样左值右值都能收;返回值用引用也还算有道理,毕竟这里复制一份参数语义和性能上都比较欠缺,因为我们要的是a和b中最大的那个,而不是最大值的副本。真正的问题是这么做之后,max的返回值不能延长a或者b的生命周期,但a和b却可以延长作为参数的临时对象的生命周期,换句话说max只能延长临时对象的生命周期到max函数运行结束。

现在还不知道问题在哪对吧,我们接着看
std::max(v, 100)
这个表达式。

其中v是没问题的,但100是字面量,在这绑定到
const int&
时必须实例化出一个int的临时对象。正是这个临时对象上发生了问题。

有人会说这个临时对象在max返回后失效了,但事实并非如此。

真相是,在一个完整的表达式里产生的临时对象,它的生命周期从被创建完成开始,一直到
完整的表达式结束时才结束

也就是说100这个临时对象在max返回后其实还存在,但max的返回值不能延长它的生命周期,value是通过引用进行间接绑定的所以也不能延长这个临时对象的生命。最后完整的表达式结束,临时对象100被消耗,现在value是悬垂引用了。

这就是典型的临时对象导致的生命周期问题。

由于这个问题太常见,所以不仅是文档和教程有列举,比较新的编译器也会有警告,比如GCC13。

除此之外就只能靠sanitizer来检测了。sanitizer是一种编译器在正常的生成代码中插入一些特殊的监测点来实现对程序行为监控的技术,比较常见的应用是检测有没有不正常的内存读写或者是多线程有没有数据竞争等问题。这里我们对悬垂引用的使用正好是一种不正常的内存读取,在检测范围内。

编译使用这个指令就能启用检测:
g++ -fsanitize=address xxx.cpp
。遇到内存相关的问题它会立刻报错并退出执行。

问题的本质在于max很容易产生临时对象,但自己又完全没法对这个临时对象的生命周期产生影响,返回值不是引用可以一定程度上规避问题,然而作为通用的库函数,这里除了用引用又没啥其他好办法。所以这得算半个设计上的失误。

不仅仅是max和min,所有参数是常量左值引用或者非转发引用的右值引用,并且返回值的类型是引用且返回的是自己的某一个参数的函数都存在相同的问题。

想彻底解决问题有点难,但回避这个问题倒是不难:

// 方案1
const int maxValue = 100;
const int &value = std::max(v, maxValue);

// 方案2
const int value = std::max(v, 100);

方案1不需要产生临时对象,value始终能引用到表达式结束后依然存在的变量。

方案2是比较推荐的,尤其是对标量类型。由于临时变量要在完整表达式结束后才销毁,所以把它复制一份给value是完全没问题的,赋值表达式也是完整表达式的一部分。这个方案的缺点在于复制成本较高或者无法复制的对象上不适用。但c++17把复制省略标准化了,这样的表达式在大多数时候不会真的产生复制行为,所以我的建议是只要业务和语义上允许,优先使用值语义也就是方案2,真出了问题并且定位到这里了再考虑转换成方案1。

链式调用中的生命周期问题

从其他语言转c++的人相当容易踩这个坑。看个最经典的例子:

const char *str = path.trimmed().toStdString().c_str();

简单说明下代码,
path
是一个
QString
的实例,
trimmed
方法会返回一个去除了首尾全部空格的新的
QString

toStdString()
会复制底层数据然后转换成一个
std::string
,c_str应该不用我多说了这个是把string内部数据转换成一个
const char*
的方法。

这句表达式同样有问题,问题在于表达式结束后str会成为悬垂指针。

一步步来分解问题。首先c_str保证返回的指针有效,前提是调用c_str的那个string对象有效。如果string对象的生命周期结束了,那么c_str返回的指针也就无效了。

path.trimmed().toStdString()
本身是没问题的,每一步都是返回的新的值类型的对象实例,但是问题在于这些对象实例都是临时对象,但我们没有做任何措施来延长临时对象的生命周期,整句表达式结束后它们就全析构生命周期终结了。

现在问题应该明了了,临时对象上调了c_str,但这个临时对象表达式结束后不存在了。所以str最后变成了悬垂指针。

为啥会坑到其他语言转来的人呢?因为对于有gc的语言,上述表达式实际上又产生了新的到临时对象的可达路径,所以对象是不会回收的,而对于rust之类的语言还可以精细控制让对象的每一部分具有不同的生命周期,上述表达式稍微改改是有机会正常使用的。这些语言转到c++把老习惯带过来就要被坑了。

推荐的解决办法只有1种:

auto tmp = path.trimmed().toStdString();
const char *str = tmp.c_str();

能解决问题,但毛病也很明显,需要多个用完就扔的变量出来,而且这个变量因为根据后续的操作要求很可能还不能用const修饰,这东西不仅干扰思维,有时候还会成为定时炸弹。

我不推荐直接用string而不用指针,是因为有时候不得不用
const char*
,这种时候啥方法都不好使,只能用上面的办法去暂存临时数据,以便让它的生命周期能延长到后续操作结束为止。

三元运算符中的生命周期问题

三元运算符中也有类似的问题。我们看个例子:

const std::string str = func();
std::string_view pretty = str.empty() ? "<empty>" : str;

很简单的一行代码,我们判断字符串是不是空的,如果是就转换成特殊的占位符字符串。用string_view当然是因为我们不想复制出一份str,所以只用string_view来引用原来的字符串,而且string_view也能引用字符串字面量,用在这里看起来正合适。

事实是这段代码无比的危险。而且
-Wall

-Wextra
都没法让编译器在编译时检测到问题,我们得用sanitizer:
g++ -std=c++20 -Wall -Wextra -fsanitize=address test.cpp
。接着运行程序,我们会看到这样的报错:
ERROR: AddressSanitizer: stack-use-after-scope on address ...

这个报错提示我们使用了某个已经析构了的变量。而且新版本的编译器还会很贴心得告诉你就是使用了
pretty
这个变量导致的。

不过虽然我们知道了具体是哪一行的那个变量导致的问题,但原因却不知道,而且当我们的字符串不为空的时候也不会触发问题。

这个时候其实就是语法规则在作祟了。

c++里规定三元运算符产生的结果最终只能有一种统一的类型。这个好理解,毕竟要赋值给某个固定类型的变量的表达式产生大于一种可能的结果类型既不合逻辑也很难正确编译。

但这导致了一个问题,如果三元运算符两边的表达式确实有不同的结果类型怎么办?现代语言通常的做法是直接报错,然而c++的做法是按照语法规则做类型转换,实在转换不来才会报错。看起来c++的做法更宽松,这反过来诱发了这节所述的问题。

我们看看具体的转换规则:

  1. 两个表达式有一边产生void值另一边不是,那么三元运算符结果的类型和另一个不是结果不是void的表达式的相同(产生void的表达式只能是throw表达式,否则算语法错误)
  2. 两个表达式都产生void,则结果也是void,这里不要求只能是throw表达式
  3. 两个表达式结果类型相同,那么三元运算符的结果类型和表达式相同
  4. 两个表达式结果类型不同或者具有不同的cv限定符,那么得看是否有其中一个类型能隐式转换成另一个,如果没有那么是语法错误,如果两方能互相转换,也是语法错误。满足这个限定条件,那么另一个类型的表达式的结果会被隐式类型转换成目标类型,比如当出现
    const char *

    std::string
    的时候,因为存在
    const char *
    隐式转换成string的方法,所以最终三元运算符的结果类型是
    std::string
    ;而
    T

    const T
    通常结果类型是
    const T

这还是我掐头去尾简化了好几次的总结版,实际的规则更复杂,如果我把实际上的规则列在那难免被喷是语言律师,所以我就不自讨没趣了。但这个简化版规则虽然粗糙,但实际开发倒是基本够用了。

回到我们出问题的表达式,因为pretty初始化后就没再修改过,那100%就是三元运算符那里有什么猫腻。恰巧的是我们正好对应在第四点上,表达式类型不同但可以进行隐式转换。

按照规则,字符串字面量
"<empty>"
要转换成
const std::string
,正好存在这样的隐式转换序列(const char[8] -> const char * -> std::string, 隐式转换序列怎么得出的可以看
这里
),当表达式为真也就是我们的字符串是空的,一个临时的string对象就被构造出来了。接着会从这个临时的string构造一个
string_view
,string_view只是简单地和原来的string共有内部数据,本身没有str的所有权,而且string_view也不是“引用”,所以它不能延长临时对象的生命周期。接着完整的表达式结束了,这时在表达式内创建的临时对象如果没有什么能延长它生命的东西存在,就会被析构。显然在这一步从
"<empty>"
转换来的临时string就析构了。

现在我们发现和
pretty
共有数据的string被销毁了,后面继续用
pretty
显然是错误的。

从别的语言转c++的开发者估计很容易踩到这种坑,短的字符串字面量转换成string在libstdc++还有特殊优化,在这个优化下你的程序就算犯了上述错误10次里还是有七八次能正常运行,然后剩下两三次得到错误或者崩溃;要是换了另一个不同的标准库实现那就有更多的未知在等着你了。这也是string_view在标准中标明的几个undefined behavior之一。所以这个错误经验不足的话会非常隐蔽。

修复倒是不难,如果能变更pretty的类型(后续可以从pretty创建string_view),那有下面几种方案可选:

// 方案1
std::string_view pretty = str;
if (str.empty()) {
    pretty = "<empty>";
}

// 方案2
const std::string pretty = str.empty() ? "<empty>" : str;

// 方案3
const std::string &pretty = str.empty() ? "<empty>" : str;

方案1里不再有类型转换和临时对象了,字符串字面量的生命周期从程序运行开始到程序退出结束,没有生命周期问题。但这个方案会显得比较啰嗦而且在字符串为空的时候得多一次赋值。

方案2也没啥特别要说的,就是前几节讲的在临时对象销毁前复制了一份。对于标量类型这么做一般没问题,对于类类型就得考虑复制成本了,不过编译器通常能做到copy elision,倒不用特别担心。

方案3其实也比较容易理解,我们不是产生了临时对象么,那么直接用常量左值引用去绑定,这样临时对象的生命周期就能被扩展延长了,而且
const T &
本来就能绑定到str这样的左值上,所以语法上没问题运行时也没有问题。

特例

说完三个典型问题,还有两个特例。

第一个是关于引用临时对象的非static数据成员的。具体例子如下:

具体的例子如下:

struct Data {
    int a;
    std::string b;
    bool c;
};

Data get_data(int a, const std::string &b, bool c)
{
    return {a, b, c};
}

int main()
{
    std::cout << get_data(1, "test", false).b << '\n';
    const auto &str = get_data(1, "test", false).b;
    std::cout << str << '\n';
}

这个例子是没有问题的。原因在于,如果我们用引用绑定了临时对象的非static数据成员,也就是subobject,那么不仅仅是数据成员,整个临时对象的生命周期都会得到延长。所以这里str虽然只绑定到了成员b,但整个临时对象会获得和str一样的生命周期,所以不会在完整的表达式结束后销毁,因此后续继续使用str是安全的。

这个subobject还包括数组元素,所以
const int &num = <temp-array>[index];
也会导致整个数组的生命周期被延长。

符合要求的形式还有很多,这里就不一一列举了。

不过这个特例带来了风险,因为完整表达式结束后我们访问不到其他成员了,但它们都还实际存在,这会留下资源泄露的隐患。现代的编程语言也基本都是这么做的,为了照顾大部分人的习惯倒也无可厚非,自己注意一下就行。

第二个特例是for-range循环。先看例子:

class Data {
    std::vector<int> data_;
public:
    Data(std::initializer_list<int> l): data_(l)
    {}

    const std::vector<int> &get_data() const
    {
        return data_;
    }
};

int main()
{
    for (const auto &v: Data{1, 2, 3, 4, 5}.get_data()) {
        std::cout << v << '\n';
    }
}

在c++23之前,这是错的,实际上我们用msvc运行会看到什么也没输出,用GCC和sanitize则直接报错了。GCC同时还会直接给出警告告诉你这里有悬垂引用。

问题倒是不难理解,for循环里冒号右侧的表达式实际上是一个完整的表达式,并且在进入for循环之前就计算完了,所以临时对象被销毁,我们通过引用返回值间接传递出来的东西自然也就失效了。

然而这是语言设计上的bug。同样作为初始化语句,
for (int i=xxx, i < xx, ++i)
中的i的生命周期就是从初始化开始,到for循环结束才结束的,所以形式上类似的for-range没有理由作为例外,否则很容易产生陷阱并限制使用上的便利性。

如果只是和普通for循环有差异那倒还好,问题是标准规定了for-range需要转换成某些规定形式,这会导致下面的结果:

// 正常的没有问题
for (const auto &v : std::vector{1,2,3,4,5}) {
    std::cout << v << '\n';
}

同样都是初始化语句里的临时变量,怎么一个有生命周期问题一个没有?因为和标准规定的转换形式有关,感兴趣的可以去深究一下。但这是实打实的行为矛盾,就像一个人早上说自己是地球人但吃完午饭就改口说自己是大猩猩一样荒谬。

这个bug也有一段时间了,直到前年才有
提案
来想办法解决,不过好消息是已经被接受进c++23了,现在for-range的初始化语句中产生的临时对象的生命周期会延长到for-range循环结束,不管是什么形式的。

可惜到目前为止,我还没看到有编译器支持(GCC 14.1,clang 18.1.8),作为临时解决办法,你只能这么写:

int main()
{
    const auto &tmp = Data{1, 2, 3, 4, 5};
    for (const auto &v: tmp.get_data()) {
        std::cout << v << '\n';
    }
}

如何发现生命周期问题

既然这些坑这么危险又这么隐蔽,那有办法及时发现防患于未然吗?

这还是比较难的,也是当今的热门研究方向。

rust选择了用类型系统+编译检测来扼杀生命周期问题,但效果不太理想,除了issue里那些bug之外,缓慢的编译速度和无法简单实现某些数据结构也是不小的问题。但整体来说还是比c++前进了很多步,上面列举的三类问题一些是语法规则禁止的,另一些则能在编译时检测出来。

c++语法已经成型也很难引进太大的变化,想及时发现问题,就得依赖这三样了:

  • constexpr
  • sanitizer
  • 静态分析

constexpr里禁止任何形式的内存泄露,也禁止越界访问和使用已经析构的数据,但这些检测只有在编译期计算时才进行,而且不是什么东西都能放进constexpr的,所以虽然能发现生命周期问题,但限制太大。

sanitizer没有constexpr那么多限制,而且检测的种类更多也更仔细,但缺点是需要程序真正运行到有问题的代码上才能上报,如果不想每次都运行整个程序你就得有一个质量上乘的单元测试集;sanitizer还会拖慢性能,以address检测器为例,平均而言会导致性能下降1到2倍,尽管已经比valgrind这样的工具快多了,但有时候还是会因为太慢而带来不便。

静态分析不需要运行实际代码,它会分析代码的调用路径和操作,然后根据一定的模式来找出看起来有问题的代码。好处是不用实际运行,安装配置简单,编译器一般还自带了一个可以用;坏处是容易误报,分析能力有时不如人类尤其是逻辑比较复杂时。

工具各有千秋,结合起来一起使用是比较常见的工程实践。

个人的知识和经验也绝不能落下,因为从编码这个源头上就扼杀生命周期问题是目前最经济有效的办法。

总结

常见的表达式中临时变量导致的生命周期问题就是这些了。

modern c++其实一直在推行值语义,一定程度上可以缓解这些问题,但c++真的太复杂了,永远没有银弹能解决所有问题。还是得自己慢慢积累知识和经验才行。

参考资料

https://en.cppreference.com/w/cpp/language/operator_other

本文分享自华为云社区
《PHP语言体系下的容器化改造,助力夺冠集团应用现代化》
,作者: HuaweiCloudDeveloper。

1、摘要

本文主要介绍了PHP语言体系应用现代化改造上云的案例。PHP在互联网公司应用广泛,PHP语言体系下的容器化改造与常见的Java语言存在一定差异,本文以夺冠集团的应用场景为背景,提供了PHP语言应用的容器改造案例,通过容器化、OPCache技术、Apollo配置中心等方案解决了弹性伸缩慢、资源利用率低、配置混乱等问题,完成生意兔等应用的华为云迁移、现代化改造等工作,效率和存储利用率提升了数倍以上。

2、背景

夺冠集团是河南头部互联网企业,致力于运用小程序产品技术,为商家和企业提供“互联网营销+数字化经营”一体化商业解决方案。夺冠集团旗下拥有夺冠魔方、生意兔、海豚知道、夺冠生活圈、小魔推、船到、小镇外卖、创意兔等众多产品线和完善的售后服务体系。近年来,夺冠凭借过硬的产品技术与服务品质在国内脱颖而出,与阿里、百度、腾讯、字节跳动等一线互联网企业均保持紧密合作。

夺冠集团应用开发以PHP语言为主,业务多样化,代表处通过多次交流均未能从商务上打动客户。DTSE介入后,针对客户业务上的痛点问题,对客户进行了深入的调研,并为客户提供了基于华为云的应用现代化改造方案,成功打动客户CTO及高层领导,获得应用现代化改造的试点机会。

3、客户业务场景分析

3.1 业务痛点

在与客户的交流中,客户表示业务最近几年可见较大的发展机会,且业务发展对资源消耗较大,但是客户业务系统的IT架构无法支撑未来业务发展,最主要的问题是弹性伸缩效率不高,会因为突发流量导致系统崩溃,同时运维效率存在瓶颈。

面对客户提出的问题,DTSE经过深入调研分析,客户的IT系统问题主要以下两个原因导致的,一是在服务扩缩容方面,客户应用直接在服务器部署,基于服务器的备份镜像做弹性伸缩,镜像大小高达200G,即浪费了弹性伸缩时间,又增加了存储成本;二是在突发流量感知方面,客户基于系统负载、CPU利用率和内存利用率进行负载监控,此类指标只有业务流量实际处理起来后才会发生变化,对于流量感知是滞后的,故不能及时的感知到突发流量。

对于运维效率方面,客户随着业务的发展,现有的运维人员已经不能满足业务运维需要,正在招聘多名运维人员。经过与运维开发同事的交流,主要是由以下两个原因导致了运维人员的效率低下,一是客户开发和运维边界混乱,很多开发环节的操作都需要运维介入,比如业务系统新版本的上线、测试环境配置的更新、日志的收集查找等等;二是客户的多个应用混合部署在多台服务器上,对应关系完全靠人工维护,且应用配置杂乱无章,完全依赖手工管理和同步。

  • 在调研过程中,还发现客户的应用系统还存在以下问题:
  • 应用间耦合部署,当发生突发流量、受到攻击时,会发生资源相互争抢等现象。
  • 未处理好弹性伸缩后,新扩容应用的负载均衡问题。
  • 应用和数据的灾备机制不健全,可靠性低。
  • PHP应用执行效率低下。

3.2 客户改造阻塞点

与客户领导沟通时,客户对于应用架构升级非常感兴趣,但是对于业务升级还是有比较大的担忧,主要在以下三个方面:首先客户希望架构升级不能给业务带来的影响;其次希望架构升级后,尽量避免对于现有开发人员技术栈的冲击;第三,希望尽量减少架构升级所带来的额外成本。考虑到企业业务稳定发展、企业技术栈与人员稳定性,客户对于升级改造存在较大的疑虑。

4 、云化架构升级改造方案

综合考虑业务问题与客户关注问题,项目组决定采用以样板改造先行,打消客户疑虑,以样板效果推动项目发展的应对策略。

4.1 改造样本选择

分析客户业务体系,当前有约20个应用,全景图如图1所示,各个应用之间的技术栈基本相同。

与客户共同商讨,建议采用循序渐进的策略,先试点后复制推广,与客户沟通后决定先选择标杆应用进行架构优化试点。

同时为了保证业务稳定,我们计划先测试后生产,提高改造效率,尽快完成试点,划定业务改造范围,为了客户体验,优先改造不需要开发人员参与的部分,对业务影响小的部分,保证改造过程平稳,其余部分则只在测试环境上优化,并由客户决定是否上生产环境。

cke_114.png

图1 夺冠集团应用技术架构全景图

针对客户关心的三个具体问题,DTSE提供了不停机的切换方案,保证架构升级的业务连续性。同时,加强客户沟通,通过高层汇报、日常项目例会为客户决策层、具体项目执行层详细说明了新架构对于开发技术栈要求不变的特点。重点介绍了新架构所能带来的资源利用率的提升,减少客户对于成本的担忧。通过技术与日常项目运作,让客户整体上消除了对于新技术带来挑战的顾虑,坚定了对改造项目的支持。

4.2 PHP容器化遇到的问题

夺冠集团所有应用的后端都是PHP语言实现的,基于PHP-FPM运行,主要有以下特点:

  • 客户应用每次请求都是一个进程,且会依次执行扫描、解析、编译,最后才会执行代码,故资源使用量极高。
  • 客户应用中的大部分进程都实现了无状态化,但是往往多个进程的代码会混杂在一起,难以拆分。
  • 客户在程序设计时,并未考虑此应用需要在云上运行,不符合云原生要素要求,因此,还有部分进程是有状态的。
  • 客户在上线新版本时,采用远程FTP的方式直接修改测试环境代码,采用git拉取的方式更新生产环境代码。

因此,对于夺冠集团的业务改造,也不单单是容器化这么简单,我们需要从业务到流程,全面的对于夺冠的应用进行改造,这并不是一个简单的事情。

4.3 应用改造方案

针对客户应用存在的痛点和问题,项目组提供了基于华为云的应用现代化改造方案,整体方案如图2所示。包括基于CCE和CCI的容器化方案、基于Apollo配置中心方案、基于流量监控的弹性伸缩方案等多个子方案。此方案优点是:

  • 应用集群基于CCE服务做容器化、无状态部署,资源相互隔离,避免相互抢占影响的现象。
  • 配置统一管理,可管、可控、可视,不再需要人工手动维护,提升运维效率。
  • 基于流量的弹性伸缩,提前感知流量变化,提高弹性伸缩反应时间。
  • 应用集群通过NAT网关实现对外部三方服务的访问,单IP外置化,不再与集群强耦合。

cke_115.png

图2 夺冠集团应用现代化改造方案

4.3.1 基于CCE和CCI的容器化方案

客户在服务器上部署的应用镜像高达200G,且多个应用混杂在同一个镜像中,所以我们并没有选择直接将应用镜像进行容器化的方案,而是对客户的业务流程进行了详细的分析和拆解,尽量将每个镜像做到最小。

以生意兔应用为例,其业务的部署架构如图3所示。

cke_116.png

图3 生意兔的部署架构

我们将生意兔的nginx路由拆出,并由k8s提供的nginx ingress替换,然后将WorkerMan的网关和注册中心拆出,剩余的生意兔业务相关的部分,因为代码耦合所以暂时部署在同一个容器中,等待客户开发人员将各个进程的代码剥离开,即可分开独立部署。最终客户业务镜像被缩减到了180M,且配合CCE和CCI,实现了秒级扩容。

在项目过程中多次因为业务流程未对齐而修改方案的情况发生,主要是因为客户对于容器化并没有清晰的概念,并不清楚那些问题会影响容器化的方案,所以建议在进行改造前对于客户开发和运维人员进行一次简单的赋能,便于问题提前暴露。

4.3.2 基于Apollo配置中心方案

对于客户配置混乱的问题,DTSE给客户提供了基于Apollo的配置中心方案,页面化操作,一键修改所有负载的配置,不再需要运维人员手动的维护。如图4所示,且Apollo也是采用容器化部署,搭建方便,如图3所示。

cke_117.png

图4 基于Apollo的配置中心方案

针对测试和生产环境,我们为客户分别部署了两套独立的环境,测试环境直接将账号提供给开发测试人员,可以由测试人员直接修改环境配置,不再需要运维参与,而生产账号由运维人员控制,并只允许运维人员修改。

4.3.3 基于流量监控的弹性伸缩方案

为了进一步解决客户弹性伸缩慢的痛点,DTSE提供了基于Prometheus流量监控的弹性伸缩方案,如图5所示。相较于通用的资源使用率做弹性伸缩,直接利用容器的网络监控数据作为弹性伸缩指标,在突发流量到来的时候更早的感知到负载的变化,更加迅速的触发弹性伸缩。基于此方案我们将客户最终弹性伸缩的时间缩短了一倍有余。

cke_118.png

图5 基于基于Prometheus流量监控的弹性伸缩方案

4.3.4 基于CodeArts的CICD方案

为了进一步解决客户运维效率低的问题,DTSE提供了基于CodeArts的CICD方案,如图6所示,建立从代码到部署的流水线,由客户开发人员自行进行新版本发布,让运维和开发人员职责归位。

cke_119.png

图6 基于CodeArts的CICD方案

并推荐客户结合业界最佳实践,在一段有限的时间内,逐步将代码QC、代码门禁、自动化测试等配置加入流水线,进一步提高自动化程度,进而提高交付质量。

4.3.5 PHP性能优化方案

针对客户PHP应用运行效率低下问题,我们发现主要是因为客户没有使用OPCache技术导致的,因为在客户原有的环境中,使用OPCache会导致新发布的版本需要三到五分钟才能生效,不利于开发和测试,所以也没有在公司内部推广,但是在容器化之后,则无需担心缓存问题,OPCache加速的原理如图7所示,使用OPCache技术可以为应用带来4倍多的性能提升。

cke_120.png

图7 OPCache加速的原理

4.4 对云服务产品的意见

对于客户关注的弹性伸缩问题,我们测试发现,当前CCE突发弹性到CCI还需要20多秒的时间,其中180M的镜像加载占用了13s,建议产品对于镜像加载过程进行优化,进一步缩短突发弹性扩容时间。

对于客户关注的成本问题,通常采用CCE和CCI配合的方案,由于CCE节点池扩容较慢,在此期间突发扩容到CCI,为了进一步减少客户成本,建议产品增加此场景的调度功能,当CCE有充足的资源时,主动将CCI上的容器调度到CCE上。

当前CodeArts Build虽然可以编译容器镜像,但是对于基础环境镜像支持不足,在很多基础环境镜像的编译时会按照很多基础组件比如make等等,会需要较高的权限,但是CodeArts Build官方环境,会因为缺乏权限而导致构建失败。

4.5 架构改造给客户带来的价值

指标

优化前

优化后

对比

备注

弹性时间

140s ~ 210s

51s ~ 81s

2倍+

从突发流量到伸缩完成

镜像存储

200G

0.18G

1000倍+

节省存储空间,提升部署速度

性能优化

RPS 14.2

RPS 61.56

4倍+

3Core6G 100并发

配置管理

600s+

20s

30倍+

无需运维人工介入

应用容灾

单可用区

多可用区

提升抗风险能力

环境一致性

不一致

一致

减少问题出现概率

隔离性

耦合部署

独立部署

避免了相互挤压

流水线

单部署

从代码到部署

从代码到部署一条流水线

5、总结和建议

根据W3 Techs的统计,PHP仍然是当今使用最广泛的服务器端语言,仍然作为互联网的主干,为至少百分之七十的网站提供后端支持[1]。尤其是在中小企业类互联网公司,PHP仍被大量使用,通常这类企业存在技术升级力量储备弱、应用架构历史债务重等问题。

牵引这类客户上云,简单的商务折扣已经难以打动,而平滑过渡的升级方案、全栈云的技术支持对其更加具有吸引力。由DTSE提供方案建议和技术支持,引导客户进行试点验证,进而推广复制,并保障业务改造的平滑过度,循序渐进的将客户业务迁移上华为云,实现客户与华为云双赢。

本文介绍了PHP语言体系应用现代化案例,实现了许多与业务无关的通用性应用改造方案,如PHP应用容器化架构方案、基于Prometheus的弹性伸缩方案等等,为此类型客户提供了一个可参考的案例。

6、参考文献

[1]
https://timotijhof.net/posts/2023/an-internet-of-php/

点击关注,第一时间了解华为云新鲜技术~

松灵机器人Scout mini小车仿真指南

之前介绍了如何通过CAN TO USB串口实现用键盘控制小车移动。但是一直用小车测试缺乏安全性。而松灵官方贴心的为我们准备了gazebo仿真环境,提供了完整的仿真支持库,本文将介绍如何上手使用仿真。

官方仓库地址:
https://github.com/agilexrobotics/ugv_gazebo_sim

1.仿真环境准备

整个功能包介绍

├── scout_control :该文件夹是小车仿真控制器功能包
├── scout_description :该文件夹是模型文件功能包
└── scout_gazebo_sim :该文件夹是gazebo模拟功能包

安装相关功能包依赖

所采用的开发环境是Ubuntu18.04+ROS Melodic

下载所需要的功能包

sudo apt-get install ros-melodic-ros-control
sudo apt-get install ros-melodic-ros-controllers
sudo apt-get install ros-melodic-gazebo-ros
sudo apt-get install ros-melodic-gazebo-ros-control
sudo apt-get install ros-melodic-joint-state-publisher-gui 
sudo apt-get install ros-melodic-teleop-twist-keyboard 

新建工作空间并初始化

打开一个新终端,创建一个名为scout_ws的工作区,并初始化:

mkdir -p scout_ws/src
cd scout_ws/src
catkin_init_workspace

下载对应的仿真模型功能包。本文采用的是scout mini。需要将
https://github.com/agilexrobotics/ugv_gazebo_sim/tree/master/scout
下的三个文件复制到src目录中

git clone https://github.com/agilexrobotics/ugv_gazebo_sim/tree/master/scout

正常编译

cd ..
rosdep install --from-paths src --ignore-src -r -y
caktin_make

2.仿真小车验证

进入
scout_ws工作空间
,运行系列车仿真模型,
display_xxxx.launch
为不同车型在rviz仿真展示。
scout_xxxx.launch
为不同车型在gazebo模拟环境中展示。

source devel/setup.bash
roslaunch scout_description display_scout_mini.launch 

img

source devel/setup.bash
roslaunch scout_gazebo_sim scout_empty_world.launch

img
如果想使用playgen地图,需要提取补全gazebo里面的models文件,不然会一直黑屏

3.添加自定义传感器

官方给定的只有小车底座,没有现成的传感器。在实际使用过程中,可以自定义一系列传感器。
scout系列车型的urdf描述文件位于
scout_description/urdf
中,在使用过程中,并未直接使用urdf进行描述,而是从xacro(xml xacro)描述文件中生成urdf信息。便于我们像编程一样复用urdf节点以及相关拆分组件等功能。

如果不了解xacro 可以参考xacro语法说明: http://wiki.ros.org/xarc

自定义简易摄像头

首先在
scout_description/urdf
中添加一个名为
universal_sensor_adder.xacro
的文件,其中保存内容如下:

<?xml version="1.0"?>
<!-- 
Author: AnthonySuen
Date: 2020-4-8
-->
<robot xmlns:xacro="http://www.ros.org/wiki/xacro" name="universal_sensor_adder">
    <!-- 用于生成一个新的传感器节点, 
    x_offset y_offset z_offset r p y对应于传感器和 base_link 的相对位置
    sensor_config 为传感器的配置信息
    sensor_plugin_config 为 gazebo 相关插件的配置内容 -->
    <xacro:macro name="add_sensor" params="sensor_name type x_offset y_offset z_offset r p y **sensor_config **sensor_plugin_config">
        <link name="sensor_${sensor_name}">
            <!-- 配置传感器视觉信息 -->
            <visual>
                <geometry>
                    <box size="0.03 0.05 0.05"/>
                </geometry>
                <material name="red">
                    <color rgba="1.0 0.0 0.0 1.0"/>
                </material>
            </visual>
            <!-- 配置碰撞体积, 为gazebo仿真使用 -->
            <collision>
                <origin xyz="0.0 0.0 0.0" rpy="0.0 0.0 0.0"/>
                <geometry>
                    <box size="0.03 0.05 0.05"/>
                </geometry>
            </collision>
            <!-- 配置惯性矩阵, 用于 gazebo 仿真,这里我假设传感器很轻很小 -->
            <inertial>
                <origin xyz="0.0 0.0 0.0" rpy="0.0 0.0 0.0"/>
                <mass value="1e-5"/>
                <inertia ixx="1e-6" ixy="1e-6" ixz="1e-6" iyy="1e-6" iyz="1e-6" izz="1e-6"/>
            </inertial>
        </link>
        <!-- 配置关节信息, 这里我默认传感器是固定在车上的 -->
        <joint name="sensor_${sensor_name}_joint" type="fixed">
            <parent link="base_link"/>
            <child link="sensor_${sensor_name}"/>
            <origin xyz="${x_offset} ${y_offset} ${z_offset}" rpy="${r} ${p} ${y}"/>
        </joint>
        <!-- gazebo 配置仿真配置部分 -->
        <gazebo reference="sensor_${sensor_name}">            <!-- 此部分要与 传感器 link 名称保持一致 -->
            <sensor name="${sensor_name}" type="${type}">
                <!-- 这里会插入传感器自身配置信息和相关插件的配置内容 -->
                <xacro:insert_block name="sensor_config"/>
                <xacro:insert_block name="sensor_plugin_config"/>
            </sensor>
        </gazebo>
    </xacro:macro>
</robot>

在使用过程中删除中文注释,xacro无法解析非ascii 字符!

编辑
empty.urdf
文件,修改内容如下:

<?xml version="1.0"?>
<!-- 
Author: AnthonySuen
Date: 2020-4-8
-->
<robot xmlns:xacro="http://www.ros.org/wiki/xacro" name="sensors">

  <!-- 加载我们之前编写的文件,之后可以使用其中相关内容 -->
  <xacro:include filename="$(find scout_description)/urdf/universal_sensor_adder.xacro" />
  <!-- 使用我们编写的函数,填入相关参数 -->
  <xacro:add_sensor sensor_name="camera" type="camera" x_offset="0.1" y_offset="0.0" z_offset="0.085" r="0.0" p="0.0" y="0.0">
  <!-- 这部分会替换 <xacro:insert_block name="sensor_config"/> 中的内容 -->
    <sensor_config>
      <update_rate>30</update_rate>
      <camera name="general_camera">
        <image width="640" height="480" hfov="1.5708" format="RGB8" near="0.01" far="50.0"/>
      </camera>
    </sensor_config>
    <!-- 这部分会替换 <xacro:insert_block name="sensor_plugin_config"/> 中的内容 -->
    <sensor_plugin_config>
      <plugin name="general_camera_controller" filename="libgazebo_ros_camera.so">
        <alwaysOn>true</alwaysOn>
        <updateRate>36.0</updateRate>
        <cameraName>sensor_camera</cameraName>
        <imageTopicName>image_raw</imageTopicName>
        <cameraInfoTopicName>camera_info</cameraInfoTopicName>
        <frameName>sensor_camera</frameName> <!-- 需要与 传感器 link 名称保持一致 -->
        <hackBaseline>0.1</hackBaseline>
        <distortionK1>0.0</distortionK1>
        <distortionK2>0.0</distortionK2>
        <distortionK3>0.0</distortionK3>
        <distortionT1>0.0</distortionT1>
        <distortionT2>0.0</distortionT2>
      </plugin>
    </sensor_plugin_config>
  </xacro:add_sensor>
</robot>

在使用过程中删除中文注释,xacro无法解析非ascii 字符!

之后再运行RIVZ查看修改后的模型

roslaunch scout_description display_mini_models.launch

img

一、概述

分类模型是机器学习中一种最常见的问题模型,在许多问题场景中有着广泛的运用,是模式识别问题中一种主要的实现手段。分类问题概况起来就是,对一堆高度抽象了的样本,由经验标定了每个样本所属的实际类别,由特定算法训练得到一个分类器,输入样本属性即自动计算出其所属类别,从而完成特定的识别任务。依实现原理的不同,分类算法有很多种,常见的如支持向量机、决策树、k近邻、朴素贝叶斯......,以及设计了的各种人工神经网络等。在特定问题中,算法性能的评价是一个重要的方面,它一般由学习器在测试数据中的性能表现来直接衡定,常见的评价指标有准确率、精准度(查准率)、召回率(查全率)、F1值、ROC-AUC值、Kappa系数等。

二、评价指标——二分类情形

二分类模型是较常见和较一般化的情形,它预测的混淆矩阵可表示为

其中,
TP
(True Positive,真正例)为被正确划分为正例的样本数;
FP
(False Positive,假正例)为被错误划分为正例的样本数;
TN
(True Negative,真负例)为被正确划分为负例的样本数;
FN
(False Negative,假负例)为被错误划分为负例的样本数。

1.准确率(accuracy)

准确率是被预测正确的样本数与样本总数
\(N\)
的比值,比较直观地反映了学习器的性能。表达式为

\[Accuracy=\frac{TP+TN}{N}
\]

2. 精准度(precision)

精准度是TP(真正例)占预测为正例样本数的百分比,它表征了预测的正例中有多少有效成分,也叫做查准率。表达式为

\[precision=\frac{TP}{TP+FP}
\]

3. 召回率(recall)

召回率是TP(真正例)与真实正样本数的比值,它表征了所有的有效内容有多少被检测了出来,也叫做查全率、灵敏度(sensitive)。表达式为

\[recall=\frac{TP}{TP+FN}
\]

4. F度量(F-Measure, F-Score)

我们希望学习器的查准率和查全率都高,而查准率和查全率往往是一对矛盾的度量,查准率高时,查全率往往偏低;查全率高时,查准率往往偏低。此时可以用F度量来综合评估精准度(查准率)和召回率(查全率),数值越高学习器越理想。它的一般形式记作
\(F_\beta\)
,表达式为

\[F_\beta=\frac{(1+\beta^2)\cdot precision\cdot recall}{\beta^2\cdot precision + recall}
\]

式中的
\(\beta\)
值能够表达出对查准率/查全率的不同偏好,如F1分数认为召回率和精准度同等重要,F2分数认为召回率的重要程度是精准度的两倍,而F0.5分数认为召回率的重要程度是精准度的一半。其中,F1度量是最常用的一种,表达式为

\[F_1=\frac{2\cdot precision\cdot recall}{precision+recall}
\]

注:
F1由调和平均进行定义: $$\frac{1}{F_1}=\frac{1}{2}\left(
\frac{1}{precision}+\frac{1}{recall} \right)$$
\(F_\beta\)
由加权调和平均进行定义:

\[\frac{1}{F_\beta}=\frac{1}{1+\beta^2 }\left(
\frac{1}{precision}+\frac{\beta^2}{recall} \right)\]

5.ROC曲线与AUC值

ROC(Receiver Operating Characteristic,受试者工作特征)曲线是以真正率(TPR)为纵轴、假正率(FPR)为横轴绘制的曲线,AUC(Area Under ROC Curve)是ROC曲线下的面积,它们能够在一定程度上衡量分类器的性能,示意图如图所示。

TPR(true positive rate,真正率)、FPR(false positive rate,假正率)表达式为

\[TPR=\frac{TP}{TP+FN}
\]

\[FPR=\frac{FP}{FP+TN}
\]


ROC与AUC的度量,对二分类问题来说是更直观的,它直接地关注于正例样本,通过正例样本的整体表现来衡量学习器的性能。而这个正例类的指定,则由实际问题情形而确定。
ROC曲线的绘制过程为,对测试样本集中的样本,将待考察的类别规定为正例类,学习器计算得到每个样本归属于正例类的概率,对所有样本依概率进行排序,起始时将划分阈值设为最大,即将所有样本划为负样本,此时TPR和FPR均为0,坐标轴上在(0,0)处标定了一个点。然后调整划分阈值,依次将每个样本划为正样本,TPR、FPR值随之增大,连接过程中标定的坐标点即得到ROC曲线。易知,若一个学习器的ROC曲线被另一个学习器的曲线完全包住,则后者的性能优于前者;若两个学习器的曲线发生交叉,则可比较ROC曲线下的面积,即AUC(Area Under ROC Curve),面积越大,性能越好。
假定ROC曲线由坐标点{(x1,y1),(x2,y2),…,(xn,yn)}按序连接而成,则AUC值估算的表达式为

\[AUC=\frac{1}{2}\sum_{i=1}^{n-1}{\left( x_{i+1}-x_i \right)\cdot\left( y_i+y_{i+1} \right)}
\]

6.Kappa系数

Kappa系数是统计分析中一种进行一致性检验的常见方法,能够较大程度上反映出分类算法的性能表现。它的计算同样基于混淆矩阵,反映的是预测结果与实际结果的一致性,其取值范围为[-1,1],值越大说明一致性越高,性能越好;反之性能越差。假设样本总数为N,Kappa系数定义为

\[kappa=\frac{p_o-p_e}{1-p_e}
\]

其中,
\(p_o=\frac{1}{N}\left( TP+TN \right)\)
是整体准确率,
\(p_e=\frac{1}{N^2}\left[ \left( TP+FN \right)\cdot \left( TP+FP \right)+\left( FP+TN \right)\cdot \left( FN+TN \right) \right]\)
表偶然一致性。

三、评价指标——多分类情形

多分类情形下的混淆矩阵表示为

多分类问题往往能够转化为多个二分类问题,对它们性能的评价可通过宏计算或微计算的方式进行。

1.准确率(accuracy)

准确率即混淆矩阵主对角线上的值之和与样本总数N的比值

\[Accuracy=\frac{1}{N}\sum_{i=1}^{K}{x_{ii}}
\]

2.精准度、召回率、F1值

对于多分类任务,各个类别两两之间即对应了一个混淆矩阵,计算查准率、查全率、F1值的方式有两种,一种是对各混淆矩阵计算得到的指标值对应求平均,得到宏查准率(macro-P)、宏查全率(macro-R)和宏F1值(macro-F1);另一种是将各混淆矩阵对应位置处的值求平均,记为
\(\bar{TP}\)

\(\bar{FP}\)

\(\bar{TN}\)

\(\bar{FN}\)
,由这些平均的值计算得到微查准率(micro-P)、微查全率(micro-R)和微F1值(micro-F1)。假设共有K个类别,那么混淆矩阵数量为
\(C_{K}^{2}=\frac{K\cdot(K-1)}{2}\)
,记为n,则
宏计算的指标值表示为

\[macro_{-}P=\frac{1}{n}\sum_{i=1}^{n}{precision_i}
\]

\[macro_{-}R=\frac{1}{n}\sum_{i=1}^{n}{recall_i}
\]

\[macrox_{-}F1=\frac{2\ast macro_{-}P\ast macro_{-}R}{macro_{-}P+macro_{-}R}
\]


微计算的指标值表示为
$$micro_{-}P=\frac{\bar{TP}}{\bar{TP}+\bar{FP}}$$
$$micro_{-}R=\frac{\bar{TP}}{\bar{TP}+\bar{FN}}$$
$$micro_{-}F1=\frac{2\ast micro_{-}P \ast micro_{-}R}{micro_{-}P+micro_{-}R}$$

3. ROC曲线与AUC值

多分类情形下的ROC与AUC同样可以以“宏”或“微”的形式进行计算。
方式一:
对于K(K>2)类分类问题,分别将每一个类别视作正例,其余各类别视作负例,在概率预测矩阵中,按当前类别列下的概率值进行排序,随之调整划分阈值,可得到一条ROC曲线,如此,共可得到K条ROC曲线,对各曲线的坐标点求平均,即绘制出一条“宏计算(macro)”的ROC曲线,并可计算出它对应的AUC值。简单地说就是,概率预测矩阵的每一列值排序后都形成一条ROC曲线,再得到平均后的曲线。

方式二:
对于K(K>2)类分类问题,首先将样本标签one-hot化,得到一个K列的0和1标识的矩阵,将概率预测矩阵和该矩阵分别按行或按列将各自拼接为一维向量,按此概率向量进行排序,随之调整划分阈值,可得到一条“微计算(micro)”的ROC曲线,并可计算出它对应的AUC值。简单地说就是,概率预测矩阵的所有值排序后直接地形成一条ROC曲线。

4.Kappa系数

Kappa的计算与二分类是一致的,假设样本类别数为K,总样本数为N,实际为i预测为j的样本数为
\(a_{ij}\)
,有

\[kappa=\frac{p_o-p_e}{1-p_e}
\]

其中,
\(p_o=\frac{1}{N}\sum_{i=1}^{K}{a_{ii}}\)
是整体准确率,
\(p_e=\frac{1}{N^2}\sum_{i=1}^{K}{a_{i+}\cdot a_{+i}}\)
表偶然一致性。

例如,在某分类问题中,样本真实类别和预测类别分别是
y_true=['A','A','C','B','B','A','C','B'];
y_pred=['C','A','C','B','B','A','B','B'],
其对应的混淆矩阵为


\(p_o=\frac{2+3+1}{8}=0.75\)

\(p_e=\frac{3\times 2+3\times 4+2\times 2}{8\times 8}=0.3438\)
所以
\(kappa=\frac{0.75-0.3438}{1-0.3438}=0.619\)

四、Python实现

二分类情形

import numpy as np
from sklearn.tree import DecisionTreeClassifier
from sklearn import metrics
import matplotlib
matplotlib.use('TkAgg')
import matplotlib.pyplot as plt
from sklearn.metrics import cohen_kappa_score

## 1.定义数据集

# 训练数据
train_x = [
    [4.8,3,1.4,0.3],
    [5.1,3.8,1.6,0.2],
    [4.6,3.2,1.4,0.2],
    [5.3,3.7,1.5,0.2],
    [5,3.3,1.4,0.2],
    [7,3.2,4.7,1.4],
    [6.4,3.2,4.5,1.5],
    [6.9,3.1,4.9,1.5],
    [5.5,2.3,4,1.3],
    [6.5,2.8,4.6,1.5]
]

# 训练数据标签
train_y = [
    'A',
    'A',
    'A',
    'A',
    'A',
    'B',
    'B',
    'B',
    'B',
    'B'
]


# 测试数据
test_x = [
    [3.1,3.5,1.4,0.2],
    [4.9,3,1.4,0.2],
    [5.1,2.5,3,1.1],
    [6.2,3.6,3.4,1.3]
]

# 测试数据标签
test_y = [
    'A',
    'A',
    'B',
    'B'
]

train_x = np.array(train_x)
train_y = np.array(train_y)
test_x = np.array(test_x)
test_y = np.array(test_y)

## 2.训练分类器
clf_dt = DecisionTreeClassifier(max_depth=None, min_samples_split=2)  # 定义决策树学习器
rclf_dt = clf_dt.fit(train_x,train_y)  # 训练

## 3.数据计算
pre_y = rclf_dt.predict(test_x)
pre_y_proba = rclf_dt.predict_proba(test_x)[:,0] # 预测为类'A'的概率

## 4.性能评价
# (1) 准确率
accuracy = metrics.accuracy_score(test_y,pre_y)

# (2) 精确度(查准率)
precision = metrics.precision_score(test_y,pre_y,pos_label='A') # pos_label指定正例类

# (3) 召回率(查全率)
recall = metrics.recall_score(test_y,pre_y,pos_label='A')

# (4) F1度量
F1 = metrics.f1_score(test_y,pre_y,pos_label='A')

# (5) ROC-AUC值
fpr,tpr,threshold = metrics.roc_curve(test_y,pre_y_proba,pos_label='A')
AUC = metrics.auc(fpr,tpr)

# (6) Kappa系数
kappa = cohen_kappa_score(test_y, pre_y)

## 5.结果输出
print('\naccuracy:', accuracy)
print('\nprecision:', precision)
print('recall:', recall)
print('F1:', F1)
print('\nAUC:', AUC)
print('\nkappa:', kappa)

# ROC曲线绘图
plt.figure()
plt.plot(fpr, tpr, label='ROC curve (area = %0.2f)' % AUC)
plt.xlim([0.0, 1.0])
plt.ylim([0.0, 1.05])
plt.xlabel('False Positive Rate')
plt.ylabel('True Positive Rate')
plt.title('Binary Classify ROC')
plt.legend(loc="lower right")
plt.show()


多分类情形

import numpy as np
from sklearn.tree import DecisionTreeClassifier
from sklearn import metrics
from sklearn.preprocessing import OneHotEncoder
from sklearn.metrics import cohen_kappa_score

## 1.定义数据集

# 训练数据
train_x = [
    [4.8,3,1.4,0.3],
    [5.1,3.8,1.6,0.2],
    [4.6,3.2,1.4,0.2],
    [5.3,3.7,1.5,0.2],
    [5,3.3,1.4,0.2],
    [7,3.2,4.7,1.4],
    [6.4,3.2,4.5,1.5],
    [6.9,3.1,4.9,1.5],
    [5.5,2.3,4,1.3],
    [6.5,2.8,4.6,1.5],
    [6.3,3.3,6,2.5],
    [5.8,2.7,5.1,1.9],
    [7.1,3,5.9,2.1],
    [6.3,2.9,5.6,1.8],
    [6.5,3,5.8,2.2]
]

# 训练数据标签
train_y = [
    'A',
    'A',
    'A',
    'A',
    'A',
    'B',
    'B',
    'B',
    'B',
    'B',
    'C',
    'C',
    'C',
    'C',
    'C'
]


# 测试数据
test_x = [
    [5.1,3.5,1.4,0.2],
    [4.9,3,1.4,0.2],
    [5.1,2.5,3,1.1],
    [5.7,2.8,4.1,1.3],
    [6.2,3.4,5.4,2.3],
    [5.9,3,5.1,1.8]
]

# 测试数据标签
test_y = [
    'A',
    'A',
    'C',
    'B',
    'C',
    'C'
]

train_x = np.array(train_x)
train_y = np.array(train_y)
test_x = np.array(test_x)
test_y = np.array(test_y)

## 2.训练分类器
clf_dt = DecisionTreeClassifier(max_depth=None, min_samples_split=2)  # 定义决策树学习器
rclf_dt = clf_dt.fit(train_x,train_y)  # 训练

## 3.数据计算
pre_y = rclf_dt.predict(test_x)
pre_y_proba = rclf_dt.predict_proba(test_x)

## 4.性能评价
# (1) 准确率
accuracy = metrics.accuracy_score(test_y,pre_y)

# (2) P-R度量
precision_macro = metrics.precision_score(test_y,pre_y,average='macro')  #宏精确度(查准率)
recall_macro = metrics.recall_score(test_y,pre_y,average='macro')  #宏召回率(查全率)
F1_macro = metrics.f1_score(test_y,pre_y,average='macro')  #宏F1度量

precision_micro = metrics.precision_score(test_y,pre_y,average='micro')  #微精确度(查准率)
recall_micro = metrics.recall_score(test_y,pre_y,average='micro')  #微召回率(查全率)
F1_micro = metrics.f1_score(test_y,pre_y,average='micro')  #微F1度量

# (3)ROC-AUC值
encoder = OneHotEncoder(sparse_output=False)  # 创建一个OneHotEncoder对象
onehot_test_y = encoder.fit_transform(test_y.reshape(-1, 1)) # 对整数数组进行one-hot编码
AUC_macro = metrics.roc_auc_score(onehot_test_y,pre_y_proba, average='macro', sample_weight=None)
AUC_micro = metrics.roc_auc_score(onehot_test_y,pre_y_proba, average='micro', sample_weight=None)

# (4) Kappa系数
kappa = cohen_kappa_score(test_y, pre_y)

## 5.结果输出
print('\naccuracy: ', accuracy)
print('\nprecision_macro: ',precision_macro)
print('recall_macro:',recall_macro)
print('F1_macro:',F1_macro)
print('\nprecision_micro:',precision_micro)
print('recall_micro:',recall_micro)
print('F1_micro:',F1_micro)
print('\nAUC_macro: ',AUC_macro)
print('AUC_micro:',AUC_micro)
print('\nKappa: ',kappa)


End.


参考:
周志华. 机器学习. 清华大学出版社.


全文pdf下载