笨办法学C 练习43:一个简单的统计引擎

练习43:一个简单的统计引擎

原文:Exercise 43: A Simple Statistics Engine

译者:飞龙

这是一个简单的算法,我将其用于“联机”(不储存任何样本)收集概要统计。我在任何需要执行一些统计,比如均值、标准差和求和中使用它,但是其中我并不会储存所需的全部样本。我只需要储存计算出的结果,它们仅仅含有5个数值。

计算标准差和均值

首先你需要一系列样本。它可以使任何事情,比如完成一个任务所需的时间,某人访问某个东西的次数,或者甚至是网站的评分。是什么并不重要,只要你能得到一些数字,并且你想要知道它们的下列概要统计值:

sum

对所有数字求和。

sumsq(平方和)

对所有数字求平方和。

count(n)

求出样本数量。

min

求出样本最小值。

max

求出样本最大值。

mean

求出样本的均值。它类似于但又不是中位数,但可作为中位数的估计。

stddev

使用$sqrt(sumsq - (sum * mean) / (n - 1) )来计算标准差,其中sqrtmath.h头文件中的平方根。

我将会使用R来验证这些计算,因为我知道R能够计算正确。

> s <- runif(n=10, max=10)
> s
 [1] 6.1061334 9.6783204 1.2747090 8.2395131 0.3333483 6.9755066 1.0626275
 [8] 7.6587523 4.9382973 9.5788115
> summary(s)
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
 0.3333  2.1910  6.5410  5.5850  8.0940  9.6780 
> sd(s)
[1] 3.547868
> sum(s)
[1] 55.84602
> sum(s * s)
[1] 425.1641
> sum(s) * mean(s)
[1] 311.8778
> sum(s * s) - sum(s) * mean(s)
[1] 113.2863
> (sum(s * s) - sum(s) * mean(s)) / (length(s) - 1)
[1] 12.58737
> sqrt((sum(s * s) - sum(s) * mean(s)) / (length(s) - 1))
[1] 3.547868
>

你并不需要懂得R,只需要看着我拆分代码来解释如何检查这些运算:

lines 1-4

我使用runit函数来获得“随机形式”的数字分布,之后将它们打印出来。我会在接下来的单元测试中用到它。

lines 5-7

这个就是概要,便于你看到R如何计算它们。

lines 8-9

这是使用sd函数计算的stddev

lines 10-11

现在我开始手动进行这一计算,首先计算sum

lines 12-13

stddev公式中的下一部分是sumsq,我可以通过sum(s * s)来得到,它告诉R将整个s列表乘以其自身,之后计算它们的sum。R的可以在整个数据结构上做运算,就像这样。

lines 14-15

观察那个公式,我之后需要sum乘上mean,所以我执行了sum(s) * mean(s)

lines 16-17

我接着将sumsq参与运算,得到sum(s * s) - sum(s) * mean(s)

lines 18-19

还需要除以n - 1,所以我执行了(sum(s * s) - sum(s) * mean(s)) / (length(s) - 1)

lines 20-21

随后,我使用sqrt算出平方根,并得到3.547868,它符合R通过sd的运算结果。

实现

这就是计算stddev的方法,现在我可以编写一些简单的代码来实现这一计算。

#ifndef lcthw_stats_h
#define lctwh_stats_h

typedef struct Stats {
    double sum;
    double sumsq;
    unsigned long n;
    double min;
    double max;
} Stats;

Stats *Stats_recreate(double sum, double sumsq, unsigned long n, double min, double max);

Stats *Stats_create();

double Stats_mean(Stats *st);

double Stats_stddev(Stats *st);

void Stats_sample(Stats *st, double s);

void Stats_dump(Stats *st);

#endif

这里你可以看到我将所需的统计量放入一个struct,并且创建了用于处理样本和获得数值的函数。实现它只是转换数字的一个练习:

#include <math.h>
#include <lcthw/stats.h>
#include <stdlib.h>
#include <lcthw/dbg.h>

Stats *Stats_recreate(double sum, double sumsq, unsigned long n, double min, double max)
{
    Stats *st = malloc(sizeof(Stats));
    check_mem(st);

    st->sum = sum;
    st->sumsq = sumsq;
    st->n = n;
    st->min = min;
    st->max = max;

    return st;

error:
    return NULL;
}

Stats *Stats_create()
{
    return Stats_recreate(0.0, 0.0, 0L, 0.0, 0.0);
}

double Stats_mean(Stats *st)
{
    return st->sum / st->n;
}

double Stats_stddev(Stats *st)
{
   return sqrt( (st->sumsq - ( st->sum * st->sum / st->n)) / (st->n - 1) );
}

void Stats_sample(Stats *st, double s)
{
    st->sum += s;
    st->sumsq += s * s;

    if(st->n == 0) {
        st->min = s;
        st->max = s;
    } else {
        if(st->min > s) st->min = s;
        if(st->max < s) st->max = s;
    }

    st->n += 1;
}

void Stats_dump(Stats *st)
{
    fprintf(stderr, "sum: %f, sumsq: %f, n: %ld, min: %f, max: %f, mean: %f, stddev: %f",
            st->sum, st->sumsq, st->n, st->min, st->max,
            Stats_mean(st), Stats_stddev(st));
}

下面是stats.c中每个函数的作用:

Stats_recreate

我希望从一些数据中加载这些数据,这和函数让我重新创建Stats结构体。

Stats_create

只是以全0的值调用Stats_recreate

Stats_mean

使用sumn计算均值。

Stats_stddev

实现我之前的公式,唯一的不同就是我使用t->sum / st->n来计算均值,而不是调用Stats_mean

Stats_sample

它用于在Stats结构体中储存数值。当你向它提供数值时,它看到n是0,并且相应地设置minmax。之后的每次调用都会使sumsumsqn增加,并且计算出这一新的样本的minmax值。

Stats_dump

简单的调试函数,用于转储统计量,便于你看到它们。

我需要干的最后一件事,就是确保这些运算正确。我打算使用我的样本,以及来自于R会话中的计算结果创建单元测试,来确保我会得到正确的结果。

#include "minunit.h"
#include <lcthw/stats.h>
#include <math.h>

const int NUM_SAMPLES = 10;
double samples[] = {
    6.1061334, 9.6783204, 1.2747090, 8.2395131, 0.3333483,
    6.9755066, 1.0626275, 7.6587523, 4.9382973, 9.5788115
};

Stats expect = {
    .sumsq = 425.1641,
    .sum = 55.84602,
    .min = 0.333,
    .max = 9.678,
    .n = 10,
};
double expect_mean = 5.584602;
double expect_stddev = 3.547868;

#define EQ(X,Y,N) (round((X) * pow(10, N)) == round((Y) * pow(10, N)))

char *test_operations()
{
    int i = 0;
    Stats *st = Stats_create();
    mu_assert(st != NULL, "Failed to create stats.");

    for(i = 0; i < NUM_SAMPLES; i++) {
        Stats_sample(st, samples[i]);
    }

    Stats_dump(st);

    mu_assert(EQ(st->sumsq, expect.sumsq, 3), "sumsq not valid");
    mu_assert(EQ(st->sum, expect.sum, 3), "sum not valid");
    mu_assert(EQ(st->min, expect.min, 3), "min not valid");
    mu_assert(EQ(st->max, expect.max, 3), "max not valid");
    mu_assert(EQ(st->n, expect.n, 3), "max not valid");
    mu_assert(EQ(expect_mean, Stats_mean(st), 3), "mean not valid");
    mu_assert(EQ(expect_stddev, Stats_stddev(st), 3), "stddev not valid");

    return NULL;
}

char *test_recreate()
{
    Stats *st = Stats_recreate(expect.sum, expect.sumsq, expect.n, expect.min, expect.max);

    mu_assert(st->sum == expect.sum, "sum not equal");
    mu_assert(st->sumsq == expect.sumsq, "sumsq not equal");
    mu_assert(st->n == expect.n, "n not equal");
    mu_assert(st->min == expect.min, "min not equal");
    mu_assert(st->max == expect.max, "max not equal");
    mu_assert(EQ(expect_mean, Stats_mean(st), 3), "mean not valid");
    mu_assert(EQ(expect_stddev, Stats_stddev(st), 3), "stddev not valid");

    return NULL;
}

char *all_tests()
{
    mu_suite_start();

    mu_run_test(test_operations);
    mu_run_test(test_recreate);

    return NULL;
}

RUN_TESTS(all_tests);

这个单元测试中没什么新东西,除了EQ宏。我比较懒,并且不想查询比较两个double值的标准方法,所以我使用了这个宏。double的问题是等性不是完全相等,因为我使用了两个不同的系统,并带有不同的四舍五入的位数。解决方案就是判断两个数“乘以10的X次方是否相等”。

我使用EQ来计算数字的10的幂,之后使用round函数来获得证书。这是个简单的方法来四舍五入N位小数,并以整数比较结果。我确定有数以亿计的其它方法能做相同的事情,但是现在我就用这种。

预期结果储存在Stats struct中,之后我只是确保我得到的数值接近R给我的数值。

如何使用

你可以使用标准差和均值来决定一个新的样本是否是“有趣”的,或者你可以使用它们计算统计量的统计量。前者对于人们来说更容易理解,所以我用登录的例子来做个简短的解释。

假设你在跟踪人们花费多长时间在一台服务器上,并且你打算用统计来分析它。每次有人登录进来,你都对它们在这里的时长保持跟踪,之后调用Stats_sample函数。我会寻找停留“过长”时间的人,以及“过短”的人。

比起设定特殊的级别,我更倾向于将一个人的停留时间与mean (plus or minus) 2 * stddev这个范围进行比较。我计算出mean2 * stddev,并且如果它们在这个范围之外,我就认为是“有趣”的。由于我使用了联机算法来维护这些统计量,所以它非常快,并且我可以使软件标记在这个范围外的用户。

这不仅仅用于找出行为异常的用户,更有助于标记一些潜在的问题,你可以查看它们来观察发生了什么。它基于所有用户的行为来计算,这也避免了你任意挑出一个数值而并不基于实际情况的问题。

你可以从中学到的通用规则是,mean (plus or minus) 2 * stddev是90%的值预期所属的范围预测值,任何在它之外的值都是有趣的。

第二种利用这些统计量的方式就是继续将其用于其它的Stats计算。基本上像通常一样使用Stats_sample,但是之后在minmaxnmeanstddev上执行Stats_sample。这会提供二级的度量,并且让你对比样本的样本。

被搞晕了吗?我会以上面的例子基础,并且假设你拥有100台服务器,每台都运行一个应用。你已经在每个应用服务器上跟踪了用户的登录时长,但是你想要比较所有的这100和应用,并且标记它们当中任何登录时间过长的用户。最简单的方式就是每次有人登录进来时,计算新的登录统计量,之后将Stats structs的元素添加到第二个Stats中。

你最后应该会得到一些统计量,它们可以这样命名:

均值的均值

这是一个Stats struct,它向你提供所有服务器的均值的meanstddev。你可以用全局视角来观察任何在此之外的用户或服务器。

标准差的均值

另一个Stats struct,计算这些服务器的分布的统计量。你之后可以分析每个服务器并且观察是否它们中的任何服务器具有异常分散的分布,通过将它们的stddev和这个mean of stddevs统计量进行对比。

你可以计算出全部统计量,但是这两个是最有用的。如果你打算监视服务器上的移除登录时间,你可以这样做:

  • 用户John登录并登出服务器A。获取服务器A的统计量,并更新它们。
  • 获取mean of means统计量,计算出A的均值并且将其加入样本。我叫它m_of_m
  • 获取mean of stddev统计量,将A的标准差添加到样本中。我叫它m_of_s
  • 如果A的meanm_of_m.mean + 2 * m_of_m.stddev范围外,标记它可能存在问题。
  • 如果A的stddevm_of_s.mean + 2 * m_of_s.stddev范围外,标记它可能存在行为异常。
  • 最后,如果John的登录时长在A的范围之外,或A的m_of_m范围之外,标记为有趣的。

通过计算“均值的均值”,或者“标准差的均值”,你可以以最小的执行和储存总量,有效地跟踪许多度量。

附加题

  • Stats_stddevStats_mean转换为static inline函数,放到stats.h文件中,而不是stats.c文件。
  • 使用这份代码来编写string_algos_test.c的性能测试。使它为可选的,并且运行基准测试作为一系列样本,之后报告结果。
  • 编写它的另一个语言的版本。确保这个版本基于我的数据正确执行。
  • 编写一个小型程序,它能从文件读取所有数字,并执行这些统计。
  • 使程序接收一个数据表,其中第一行是表头,剩下的行含有任意数量空格分隔的数值。你的程序应该按照表头中的名称,打印出每一列的统计值。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容