如何在不存储列表的情况下计算或近似计算中位数

50

我想计算一组值的中位数,但我不想存储所有值,因为那可能会使内存要求过高。有没有一种方法可以在不存储和排序所有单个值的情况下计算或近似中位数?

理想情况下,我希望编写的代码类似于以下内容

var medianCalculator = new MedianCalculator();
foreach (var value in SourceData)
{
  medianCalculator.Add(value);
}
Console.WriteLine("The median is: {0}", medianCalculator.Median);

我需要的只是实际的MedianCalculator代码!

更新:有些人问我要计算中位数的值是否具有已知属性。答案是肯定的。一个值从大约-25到-0.5以0.5为增量递增。另一个值也以0.5为增量从-120到-60递增。我想这意味着我可以使用某种直方图来处理每个值。

谢谢

Nick


你是在寻找一个通用的算法(如果是这样的话,似乎是不可能的),还是你的输入具有一些可以用于设计特定解决方案的属性? - mouviciel
一个由http://stackoverflow.com/users/25188/john-d-cook实现的C++程序,用于“在内存受限应用中计算百分位数”,http://www.codeproject.com/KB/recipes/TailKeeper.aspx。另请参阅https://dev59.com/8nNA5IYBdhLWcg3wL6ux。 - Alessandro Jacopson
你可以保留一个运行样本(例如使用蓄水池抽样)的大小,以便你感到舒适。然后,在最后,你可以计算样本的中位数。(如果你想在读取过程中快速访问它,这也可以通过运行中位数进行扩展。) - Thomas Ahle
11个回答

48

如果值是离散的并且不同值的数量不太高,您可以仅累积每个值出现的次数以生成直方图,然后从直方图计数中找到中位数(只需从直方图顶部和底部累加计数,直到到达中间即可)。或者如果它们是连续值,您可以将它们分布到桶中-这不会告诉您确切的中位数,但它会给您一个范围,如果您需要更精确地知道,您可以再次遍历列表,仅检查中心桶中的元素。


1
我喜欢这个答案。因为我知道被存储的值的类型并且可以相当容易地构建一个直方图,所以它是一个好主意。 - Nick Randell

46

这里介绍一种名为“remedian(中位数滑动窗口)”的统计方法。它通过首先设置k个长度为b的数组来工作。将数据值输入第一个数组,当该数组已满时,计算并存储其中位数于下一个数组的第一个位置,然后重新使用第一个数组。当第二个数组也已满时,将其值的中位数存储在第三个数组的第一个位置上,以此类推。您明白了吧 :)

这个方法简单而且相当健壮。您可以查看参考文献...

http://web.ipac.caltech.edu/staff/fmasci/home/astro_refs/Remedian.pdf

希望这有所帮助

迈克尔


1
还有一篇更新的论文:https://www.sciencedirect.com/science/article/pii/S0304397513004519 - S.A.

23

我使用这些增量/递归平均值和中位数估算器,两者都使用恒定的存储空间:

mean += eta * (sample - mean)
median += eta * sgn(sample - median)

其中eta是一个小的学习率参数(例如0.001),sgn()是符号函数,返回{-1, 0, 1}中的一个。(如果数据非平稳且要跟踪时间内的变化,请使用常量eta;否则,对于平稳源,可以使用像eta=1/n这样的东西作为均值估计器,其中n是迄今为止看到的样本数... 不幸的是,这似乎不能用于中位数估计器。)

这种增量均值估计器似乎在很多地方都被使用,比如无监督神经网络学习规则,但中位数版本似乎要少得多,尽管它具有优点(对离群值的鲁棒性)。这意味着中位数版本可以在许多应用程序中替代均值估计器。

此外,我修改了增量中位数估计器以估计任意分位数。一般来说,分位数函数告诉您将数据分成两个部分的值:p和1-p。以下逐步估计该值:

quantile += eta * (sgn(sample - quantile) + 2.0 * p - 1.0)

变量p的值应该在[0,1]之间。这本质上将sgn()函数的对称输出{-1、0、1}偏向一侧,将数据样本分成两个不同大小的箱子(数据中小于/大于分位数估计的比例分别为p和1-p)。请注意,当p=0.5时,这就简化成了中位数估计。

我很想看到一个类似形式的增量模式估计器...

(注:我也在这里发布了一个类似的话题:“用于估算统计中位数、模式、偏度、峰度的“在线”(迭代器)算法?”)


8
中位数估计法不适用于非高斯分布。想象一下有一个分布,你只有两个数字:x出现了n次,x+y出现了n+m次。如果m或y相对较大,则估计会失效。例如:你看到100出现了10m+1次,然后1出现了100k次。真实的中位数为100,估计的中位数为1。 - Eli
当然,这仍然是一个估计。请注意,可以通过进行大量迭代次数n来克服此问题。随着n趋近于无穷大,误差将收敛到eta,但即使如此,也可以通过逐渐降低eta来克服这个问题。 - Tim Kuipers
这正是我需要的!如此简单!谢谢! - Ihle
1
关于均值,为什么要使用近似公式而不是精确的递归公式 mean += (sample - mean) / N(参见更好的描述)? - bluenote10
@bluenote10 很好的观点。使用常数eta可以很好地跟踪非平稳源,而eta=1/n(相当于您所写的)则适用于平稳情况。我已更新帖子以反映这种区别。 - Tyler Streeter

15

这里有一种疯狂的方法,也许你可以尝试一下。这是流算法中的一个经典问题。规则如下:

  1. 你拥有有限的内存,比如说 O(log n),其中 n 是你想要的项目数
  2. 你只能查看每个项目一次,并在那里决定对其进行什么操作,如果存储它,则会占用内存,如果将其丢弃,则会永久丢失。

找到中位数的思路很简单。从列表中随机采样 O(1 / a^2 * log(1 / p)) * log(n) 个元素,你可以通过蓄水池抽样(见之前的问题)来做到这一点。现在,只需使用经典方法从采样的元素中返回中位数即可。

保证返回的项的索引将以至少为 1-p 的概率为 (1 +/- a) / 2。因此有可能失败,你可以通过采样更多的元素来选择它。它不会返回中位数,也不能保证返回的项的值接近于中位数,只是当你对列表进行排序时,返回的项将接近列表的一半。

该算法使用额外的 O(log n) 空间,并且在线性时间内运行。


你好,请问一下 ap 分别代表什么?另外,你提供的链接现在已经失效了。 - user7364588
你可以选择足够小的a和p,这意味着你可以更有可能得到更好的近似值,但时间复杂度会增加。 - Pall Melsted
a是a-近似中位数。这意味着该算法将在[(1-a)realMedianPosition,(1+a)realMedianPosition]之间产生结果。而p是误差概率(该算法具有等于p的误差概率)。 - ladytoky0

8
这在一般情况下很难正确处理,特别是要处理已经排序的退化序列,或者列表开头有一堆值,但列表结尾的值在不同的范围内。
制作直方图的基本思路最为可行。这让您可以积累分布信息并从中回答查询(如中位数)。由于您显然没有存储所有值,因此中位数将是近似值。存储空间是固定的,因此它适用于任何长度的序列。
但是,您不能仅从前100个值构建直方图并持续使用该直方图...正在更改的数据可能会使该直方图无效。因此,您需要一个动态直方图,可以根据需要动态更改其范围和区间。
创建一个具有N个区间的结构。您将存储每个插槽转换的X值(总共N + 1个值)以及该区间的人口数量。
流式传输您的数据。记录前N + 1个值。如果流在此之前结束,那么很好,您已经加载了所有值,并且可以找到确切的中位数并返回它。否则,请使用这些值来定义您的第一个直方图。只需对值进行排序,然后使用这些值作为区间定义,每个区间的人口为1。拥有重复项(0宽度区间)是可以的。
现在流式传入新值。对于每个值,二分搜索以找到它所属的区间。在常见情况下,您只需增加该区间的人口并继续进行。如果样本超出了直方图的边缘(最高或最低),则将结束区间的范围扩展以包括它。当您的流完成时,通过找到两侧均具有相等人口的区间来找到中位数样本值,并线性插值剩余的区间宽度。
但这还不够...您仍然需要根据正在流式传输的数据调整直方图。当一个区间过满时,您正在失去该区间的子分布信息。您可以通过基于某些启发式方法进行适应来解决此问题...最简单和最强大的方法是如果一个区间达到某个特定阈值人口(例如10 * v / N,其中v =流中迄今为止看到的值的数量,N是区间数),则将该过度填充的区间分成两半。在区间的中点添加一个新值,将原始区间的一半分配给每个侧。但现在您有太多的区间,因此需要删除一个区间。一个好的启发式方法是找到人口和宽度乘积最小的区间。将其删除并与其左侧或右侧的邻居合并(哪个邻居本身的宽度和人口乘积最小)。完成!
请注意,合并或分割区间会丢失信息,但这是不可避免的...您只有固定的存储空间。
此算法很好,在处理所有类型的输入流时都能给出良好的结果。如果您有选择样本顺序的奢侈品,则随机样本最佳,因为这可以最小化分裂和合并。
该算法还允许您查询任何百分位数,而不仅仅是中位数,因为您具有完整的分布估计。
我在自己的代码中多次使用这种方法,主要用于调试日志...其中一些正在记录的统计数据具有未知分布。使用此算法,您无需提前猜测。
缺点是不等宽箱子意味着您必须为每个样本执行二进制搜索,因此您的净算法为O(NlogN)。

谢谢 - 这是一个很好的答案,但对我的要求可能太昂贵了。我需要在一个大的地理区域内有很多中位数,并为每个200米乘200米的区域设置不同的中位数。 - Nick Randell
1
有一些好的想法,但我卡在一个问题上——当你把一个箱子分成两个时,你如何决定多少个来自该箱子的物品放入子箱 #1,多少个放入子箱 #2?看起来你需要记录箱子中的每个值(因为一个箱子可能会被多次细分)。 - j_random_hacker
JRH: 你将人口分成两部分,分别分配到每个箱子中。我们没有存储有关内部子箱数据分布的更多信息,所以除了允许更好的数据分辨率外,我们无法做更多事情。 - SPWorley
@Arno:感谢您的澄清。对我来说,这似乎是一种有损但可行的方法。加一。 - j_random_hacker

4

对于近似中位数的计算,David的建议似乎是最明智的方法。

同样问题的运行平均数要容易计算得多:

Mn = Mn-1 + ((Vn - Mn-1) / n)

其中Mn是n个值的平均值,Mn-1是前一个平均值,而Vn是新值。

换句话说,新平均值等于现有平均值加上新值与平均值之间的差异,再除以值的数量。

在代码中,这看起来可能是这样的:

new_mean = prev_mean + ((value - prev_mean) / count)

尽管显然您可能需要考虑特定于语言的内容,如浮点数舍入误差等。

2
他们正在要求中位数。 - GilbertS

3

我认为在没有将列表导入内存的情况下无法做到。如果您知道数据是对称分布的,可以使用平均值进行近似计算,或者计算一小部分数据的适当中位数(适合内存),如果您知道样本中具有相同分布的数据(例如,第一个元素与最后一个元素具有相同的分布)。

  • 如果您知道数据是对称分布的,则可以使用平均值进行近似计算
  • 或者计算一小部分数据的适当中位数(适合内存)- 如果您知道您的数据在样本中具有相同的分布(例如,第一个项目与最后一个项目具有相同的分布)

2
通过线性搜索找到包含N个项目的列表的最小值和最大值,并将它们命名为HighValue和LowValue。让MedianIndex =(N + 1)/ 2。
第一次二分查找:
重复以下4个步骤,直到LowValue < HighValue。
1. 获取中位数Approximately =(HighValue + LowValue)/ 2 2. 获取NumberOfItemsWhichAreLessThanorEqualToMedianValue = K 3. 如果K = MedianIndex,则返回MedianValue 4. 如果K> MedianIndex,则HighValue = MedianValue,否则LowValue = MedianValue 不会消耗内存,速度更快
第二次二分查找:
LowIndex = 1 HighIndex = N
重复以下5个步骤,直到(LowIndex < HighIndex)
1. 获取Approximate DistrbutionPerUnit =(HighValue-LowValue)/(HighIndex-LowIndex) 2. 获取Approximate MedianValue = LowValue +(MedianIndex-LowIndex)* DistributionPerUnit 3. 获取NumberOfItemsWhichAreLessThanorEqualToMedianValue = K 4. 如果(K = MedianIndex),则返回MedianValue 5. 如果(K> MedianIndex),则HighIndex = K且HighValue = MedianValue,否则LowIndex = K且LowValue = MedianValue 不会消耗内存,比第一次二分查找更快
我们还可以考虑将HighValue,LowValue和MedianValue与HighIndex,LowIndex和MedianIndex拟合成抛物线,从而得到比第二阶更快的第三阶二分查找,而不会消耗内存,依此类推...

这是一种不错的方法,但请明确提到它需要多次(期望中的log(n))通过数据,因为您不会在RAM中保留NumberOfItemsWhichAreLessThanorEqualToMedianValue[k]。 - j_random_hacker

0
通常,如果输入在某个范围内,比如1到100万之间,那么很容易创建一个计数数组:在这里阅读“分位数”和“ibucket”的代码:http://code.google.com/p/ea-utils/source/browse/trunk/clipper/sam-stats.cpp 这个解决方案可以推广为通过使用一个函数将输入强制转换为某个范围内的整数来进行近似,然后在输出时反转它:例如:foo.push((int) input/1000000) 和 quantile(foo)*1000000。
如果您的输入是任意双精度数字,则必须在超出范围的值进入时自动缩放直方图(参见上文)。
或者,您可以使用本文中描述的中位数三元组方法:http://web.cs.wpi.edu/~hofri/medsel.pdf

0

我学习了迭代分位数计算的思路。在选择起始点和eta值时非常重要,这些值可以来自均值和标准差。因此,我编写了以下代码:

Function QuantileIterative(Var x : Array of Double; n : Integer; p, mean, sigma : Double) : Double;
Var eta, quantile,q1, dq : Double;
    i : Integer;
Begin
  quantile:= mean + 1.25*sigma*(p-0.5);
  q1:=quantile;
  eta:=0.2*sigma/xy(1+n,0.75); // should not be too large! sets accuracy
  For i:=1 to n Do 
     quantile := quantile + eta * (signum_smooth(x[i] - quantile,eta) + 2*p - 1);
  dq:=abs(q1-quantile);
  If dq>eta
     then Begin
          If dq<3*eta then eta:=eta/4;
          For i:=1 to n Do 
             quantile := quantile + eta * (signum_smooth(x[i] - quantile,eta) + 2*p - 1);
     end;
  QuantileIterative:=quantile
end;

由于两个元素的中位数是均值,所以我使用了平滑符号函数,而xy() 是 x^y。有没有更好的想法?当然,如果我们有一些先验知识,我们可以使用数组的最小值和最大值,偏度等添加代码。对于大数据,您可能不会使用数组,但对于测试来说,这样更容易。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接