样本熵(SampEn)的C/C++代码实现与优化

正文

本文不介绍什么是样本熵,具体推荐看此文https://blog.csdn.net/Cratial/article/details/79742363,写的很好,里面的示例也被我拿来测试代码写的对不对。

本文所有代码可以在此处找到https://gitcode.net/PeaZomboss/miscellaneous,文件夹231424-sampen

前言

一开始有人找我帮忙写些C++代码,实现一些算法,这其中呢就有一个是样本熵。

当时粗写了一个,后来似乎性能不太够,当然实际上性能是没问题的,虽然确实不如现在优化的,但也不会很差,原因是代码被改了一部分,当然这个和算法的优化没关系,就不展开了。

不过既然都要优化了,那不如在算法层面也做一些改进,让速度再快一点,于是就想了想办法提升了性能。于是在此之后就整理了一下代码,并严谨测试,然后与大家分享。

开头提到的那篇文章里有用python写的代码,还有一个matlab写的。但是不管是python还是matlab都不是拿来实际应用的,一般追求性能的模块会用C/C++,当然用Java这类的应该也不会太差。

代码实现

首先是一般情况下很容易想到的代码:

static double step(double *X, int N, int m, double r)
{
    double sum = 0;
    for (int i = 0; i <= N - m; i++) {
        int Bi = 0;
        for (int j = 0; j <= N - m; j++) {
            if (i != j) {
                /* 找出最大差值 */
                double D = fabs(X[i] - X[j]);
                for (int k = 1; k < m; k++) {
                    double t = fabs(X[i + k] - X[j + k]);
                    if (D < t)
                        D = t;
                }
                if (D <= r)
                    Bi++;
            }
        }
        sum += 1.0 * Bi / (N - m); // 会有累加误差
    }
    return sum / (N - m + 1);
}

double SampEn(double *X, int N, int m, double r)
{
    double B = step(X, N, m, r);
    if (B == 0) // 尽管大部分时候不会是0
        return 0;
    double A = step(X, N, m + 1, r);
    if (A == 0)
        return 0;
    return -log(A / B);
}

这里的所有步骤基本都是按照原算法描述的内容进行编写的,所以非常好理解啊。而且我已经故意避开了诸如fmax之类的函数,优化找最大差值的逻辑,都是为了提高性能。

不过这个step函数的sum += 1.0 * Bi / (N - m);在N较大的时候累加误差会增加,而实际上这个Bi变量完全可以累加到最后在进行运算,不但减少误差还能降低运算量,所以可以改成:

static double step(double *X, int N, int m, double r)
{
    int Bi = 0;
    for (int i = 0; i <= N - m; i++) {
        for (int j = 0; j <= N - m; j++) {
            if (i != j) {
                double D = fabs(X[i] - X[j]);
                for (int k = 1; k < m; k++) {
                    double t = fabs(X[i + k] - X[j + k]);
                    if (D < t)
                        D = t;
                }
                if (D <= r)
                    Bi++;
            }
        }
    }
    return 1.0 * Bi / (N - m) / (N - m + 1);
}

这样子几乎就是按照原来的算法思路进行了一些简单处理,但提升并不明显。

代码优化

仔细看这个step()函数,那复杂度可是O(n^2)啊,而且还要跑两次,那损耗肯定惊人。但是仔细看这两次计算,只有第一次的m变成了m+1,那能不能把两次运算整合到一起呢?

注意到m+1和m的区别在于循环次数少了一次,寻找最大差值的向量多了一个,其余是没有区别的,那我们完全可以在处理m的时候顺便把m+1的情况也放一起就行了。

请看:

double FastSampEn(double *X, int N, int m, double r)
{
    int Ai = 0, Bi = 0;
    int LoopsSub1 = N - m; // 循环次数减一,因为算法中的表述是从1到N-m+1,而我们是从0开始的
    for (int i = 0; i <= LoopsSub1; i++) {
        for (int j = 0; j <= LoopsSub1; j++) {
            if (i != j) {
                // 这里一样找m个的最大差值
                double D = fabs(X[i] - X[j]);
                for (int k = 1; k < m; k++) {
                    double t = fabs(X[i + k] - X[j + k]);
                    if (D < t)
                        D = t;
                }
                if (D <= r)
                    Bi++;
                // 对于m+1的情况,当到达m维数的边界的时候显然是不行的
                // 所以我们只要限制边界情况就行了
                if (i != LoopsSub1 && j != LoopsSub1) {
                    double t = fabs(X[i + m] - X[j + m]);
                    if (D < t) // 判断最后一个是不是最大值
                        D = t;
                    if (D <= r)
                        Ai++;
                }
            } // i!=j
        } // j
    } // i
    double B = 1.0 * Bi / (N - m) / (N - m + 1);
    double A = 1.0 * Ai / (N - m - 1) / (N - m);
    if (B == 0 || A == 0)
        return 0;
    return -log(A / B);
}

这样修改以后速度可以达到原先的1.5倍,算上循环内部的开销,这样的情况也是挺不错的。

至此,针对样本熵代码的基本优化我能想到的就到这了,不过后面还有进一步的优化。

考虑到实际应用情况,这个m大多数时候都是取值2,所以针对m为2的情况又专门优化了一下:

double FastSampEn_m2(double *X, int N, double r)
{
    int Ai = 0, Bi = 0;
    int LoopsSub1 = N - 2;
    for (int i = 0; i <= LoopsSub1; i++) {
        for (int j = 0; j <= LoopsSub1; j++) {
            if (i != j) {
                double D = fabs(X[i] - X[j]);
                double t = fabs(X[i + 1] - X[j + 1]);
                if (D < t)
                    D = t;
                if (D <= r)
                    Bi++;
                if (i != LoopsSub1 && j != LoopsSub1) {
                    double t = fabs(X[i + 2] - X[j + 2]);
                    if (D < t)
                        D = t;
                    if (D <= r)
                        Ai++;
                }
            }
        }
    }
    double B = 1.0 * Bi / (N - 2) / (N - 1);
    double A = 1.0 * Ai / (N - 3) / (N - 2);
    if (B == 0 || A == 0)
        return 0;
    return -log(A / B);
}

在m取2的情况下可以稍微快一点点。

不过这个代码还是有比较大的提升空间,我找了ChatGPT给我提供优化,但是结果一直出错,后来我分析了一下代码,手动调整了一下,得出了正确的代码,然后再让AI分析。经过一段时间的操作,现在得出了一个效率更高的代码,而且没有任何额外的内存占用。

double FastSampEn_ai(double *X, int N, int m, double r)
{
    int Ai = 0, Bi = 0;
    int LoopTimes = N - m + 1;
    for (int i = 0; i < LoopTimes; i++) {
        for (int j = i + 1; j < LoopTimes; j++) {
            double D = fabs(X[i] - X[j]);
            for (int k = 1; k < m; k++) {
                double t = fabs(X[i + k] - X[j + k]);
                if (t > D)
                    D = t;
            }
            if (D <= r)
                Bi++;
            if (j < N - m) {
                double t = fabs(X[i + m] - X[j + m]);
                if (t > D)
                    D = t;
                if (D <= r)
                    Ai++;
            }
        }
    }
    double B = 1.0 * Bi / ((N - m + 1) * (N - m) / 2);
    double A = 1.0 * Ai / ((N - m) * (N - m - 1) / 2);
    if (A == 0 || B == 0)
        return 0;
    return -log(A / B);
}

简单分析下思路,因为两层循环里面,实际上有一半的计算是重复的,所以直接使用for (int j=i+1;...)可以减少一半的计算量,最后只要再让结果乘个2就好了。其实AI提出了好几种办法,比如使用空间换时间的方法等,但是存在不少bug,最后我采纳了这个比较简单明了且效果好的代码,并进行了微调测试,最终使速度为原来的两倍。

然后再写一个手动替换m为2的情况:

double FastSampEn_ai_m2(double* X, int N, double r)
{
    int Ai = 0, Bi = 0;
    int LoopTimes = N - 1;
    for (int i = 0; i < LoopTimes; i++) {
        for (int j = i + 1; j < LoopTimes; j++) {
            double D = fabs(X[i] - X[j]);
            double t = fabs(X[i+1]-X[j+1]);
            if (t > D)
                D = t;
            if (D <= r)
                Bi++;
            if (j < N - 2) {
                double t = fabs(X[i + 2] - X[j + 2]);
                if (t > D)
                    D = t;
                if (D <= r)
                    Ai++;
            }
        }
    }
    double B = 1.0 * Bi / ((N - 1) * (N - 2) / 2);
    double A = 1.0 * Ai / ((N - 2) * (N - 3) / 2);
    if (A == 0 || B == 0)
        return 0;
    return -log(A / B);
}

这应该是开销最小的速度基本达到极限的单线程方法了。

测试代码

接下来为了测试上述代码是否正确,就用开头提到的那篇文章的数据进行测试看看:

std::vector<double> x;
for (int i = 0; i < 17; i++) {
    x.push_back(85);
    x.push_back(80);
    x.push_back(89);
}
std::cout << SampEn(x.data(), x.size(), 2, 3) << '\n';
std::cout << FastSampEn(x.data(), x.size(), 2, 3) << '\n';
std::cout << FastSampEn_m2(x.data(), x.size(), 3) << '\n';
std::cout << FastSampEn_ai(x.data(), x.size(), 2, 3) << '\n';
std::cout << FastSampEn_ai_m2(x.data(), x.size(), 3) << '\n';

原文给出的结果是0.0008507018803128114,不过由于std::cout的舍入问题,实际输出的是0.000850702,是符合结果的。

然后再测试一下性能:

for (int i = 0; i < 10000; i++) {
    x.push_back(85);
    x.push_back(80);
    x.push_back(89);
}

double se;
clock_t t;

t = clock();
se = SampEn(x.data(), x.size(), 2, 3);
t = clock() - t;
std::cout << se << ", time = " << t << " ms\n";

t = clock();
se = FastSampEn(x.data(), x.size(), 2, 3);
t = clock() - t;
std::cout << se << ", time = " << t << " ms\n";

t = clock();
se = FastSampEn_m2(x.data(), x.size(), 3);
t = clock() - t;
std::cout << se << ", time = " << t << " ms\n";

t = clock();
se = FastSampEn_ai(x.data(), x.size(), 2, 3);
t = clock() - t;
std::cout << se << ", time = " << t << " ms\n";

t = clock();
se = FastSampEn_ai_m2(x.data(), x.size(), 3);
t = clock() - t;
std::cout << se << ", time = " << t << " ms\n";

先是塞了30000个数据,然后用clock()函数计时,依次测试每一个函数,然后看看结果是否一致,时间差距有多少。

如果有兴趣的话,可以去用之前提到那篇文章的python代码跑跑看,30000个数据花了我半个多小时才跑完,而我的这个代码即使没经过优化也不超过一分钟。而matlab的代码我没试过,电脑上没这个软件,也不是学这个的。

完整代码在开头给出的仓库里,打开test.cpp就可以看到。

运行测试

编译器和版本:g++ (x86_64-win32-seh-rev0, Built by MinGW-W64 project) 8.5.0

测试机器CPU:AMD Ryzen 5 4600H

测试操作系统:Windows 11 21H2

  • 编译命令g++ -D ALL_IN_ONE test.cpp -o test_x64_O0 -O0
0.000850702
0.000850702
0.000850702
0.000850702
0.000850702
2.21505e-09, time = 11192 ms
2.21505e-09, time = 6663 ms
2.21505e-09, time = 5560 ms
2.21505e-09, time = 3150 ms
2.21505e-09, time = 2566 ms
  • 编译命令g++ -D ALL_IN_ONE test.cpp -o test_x64_O1 -O1
0.000850702
0.000850702
0.000850702
0.000850702
0.000850702
2.21505e-09, time = 2483 ms
2.21505e-09, time = 1619 ms
2.21505e-09, time = 1143 ms
2.21505e-09, time = 637 ms
2.21505e-09, time = 515 ms
  • 编译命令g++ -D ALL_IN_ONE test.cpp -o test_x64_O2 -O2
0.000850702
0.000850702
0.000850702
0.000850702
0.000850702
2.21505e-09, time = 2703 ms
2.21505e-09, time = 1622 ms
2.21505e-09, time = 1159 ms
2.21505e-09, time = 692 ms
2.21505e-09, time = 523 ms
  • 编译命令g++ -D ALL_IN_ONE test.cpp -o test_x64_O3 -O3
0.000850702
0.000850702
0.000850702
0.000850702
0.000850702
2.21505e-09, time = 2641 ms
2.21505e-09, time = 1614 ms
2.21505e-09, time = 1160 ms
2.21505e-09, time = 727 ms
2.21505e-09, time = 359 ms
  • 编译命令g++ -D ALL_IN_ONE test.cpp -o test_x64_Os -Os
0.000850702
0.000850702
0.000850702
0.000850702
0.000850702
2.21505e-09, time = 2969 ms
2.21505e-09, time = 1639 ms
2.21505e-09, time = 1156 ms
2.21505e-09, time = 803 ms
2.21505e-09, time = 591 ms

32位g++未作测试,但是理论上速度肯定是会更快的。

  • 使用VC++2022在x64的Release模式下编译
0.000850702
0.000850702
0.000850702
0.000850702
0.000850702
2.21505e-09, time = 2950 ms
2.21505e-09, time = 1854 ms
2.21505e-09, time = 1071 ms
2.21505e-09, time = 852 ms
2.21505e-09, time = 506 ms
  • 使用VC++2022在x86的Release模式下编译
0.000850702
0.000850702
0.000850702
0.000850702
0.000850702
2.21505e-09, time = 3882 ms
2.21505e-09, time = 2343 ms
2.21505e-09, time = 1177 ms
2.21505e-09, time = 1026 ms
2.21505e-09, time = 505 ms

可以看到提升还是显著的,尤其是AI优化的部分,比我手动优化的还快一倍。

更新记录

  • 2023-02-04:修正错别字,优化部分表述。
  • 2023-03-07:新增AI的优化方案以及测试结果,删去不必要的内容。
posted @ 2023-01-30 00:09  PeaZomboss  阅读(275)  评论(0编辑  收藏  举报