从PostgreSQL表中选择具有加权行概率的随机行

时间:2012-10-23 22:22:27

标签: sql postgresql statistics probability

示例输入:

SELECT * FROM test;
 id | percent   
----+----------
  1 | 50 
  2 | 35   
  3 | 15   
(3 rows)

你会如何写这样的查询,平均50%的时间我可以获得id = 1的行,35%的时间行id = 2,15%的时间行id = 3 ?

我尝试了类似SELECT id FROM test ORDER BY p * random() DESC LIMIT 1的内容,但却给出了错误的结果。在10,000次运行后,我得到了一个类似于{1=6293, 2=3302, 3=405}的分布,但我预计分布几乎为:{1=5000, 2=3500, 3=1500}

有什么想法吗?

6 个答案:

答案 0 :(得分:22)

这应该可以解决问题:

WITH CTE AS (
    SELECT random() * (SELECT SUM(percent) FROM YOUR_TABLE) R
)
SELECT *
FROM (
    SELECT id, SUM(percent) OVER (ORDER BY id) S, R
    FROM YOUR_TABLE CROSS JOIN CTE
) Q
WHERE S >= R
ORDER BY id
LIMIT 1;

子查询Q给出以下结果:

1  50
2  85
3  100

然后,我们只需在[0,100]范围内生成一个随机数,然后选择该数字处或之外的第一行(WHERE子句)。我们使用公用表表达式(WITH)来确保随机数只计算一次。

BTW,SELECT SUM(percent) FROM YOUR_TABLE允许您在percent中拥有任何权重 - 它们不一定是百分比(即加起来为100)。 < / p>

[SQL Fiddle]

答案 1 :(得分:3)

  

ORDER BY random()^(1.0 / p)

来自Efraimidis和Spirakis描述的算法。

答案 2 :(得分:2)

您提出的查询似乎有效;见this SQLFiddle demo。它创造了错误的分布;见下文。

为了防止PostgreSQL优化子查询,我将其包装在VOLATILE SQL函数中。 PostgreSQL没有办法知道你打算让子查询为外部查询的每一行运行一次,所以如果你不强制它挥发它只会执行一次。另一种可能性 - 虽然查询计划程序可能在将来优化 - 是使它看起来像一个相关的子查询,就像这个使用always-true where子句的hack一样,如下所示:http://sqlfiddle.com/#!12/3039b/9

猜测(在您更新之前解释为什么它不起作用)您的测试方法有问题,或者您将其用作PostgreSQL注意到的外部查询中的子查询它不是一个相关的子查询,只执行一次,就像在this example中一样。

更新:产生的分配不是您所期望的。这里的问题是你通过采用random()多个样本来扭曲分布;你需要一个单个样本。

此查询生成正确的分布(SQLFiddle):

WITH random_weight(rw) AS (SELECT random() * (SELECT sum(percent) FROM test))
 SELECT id
FROM (                   
  SELECT 
    id,
    sum(percent) OVER (ORDER BY id),
    coalesce(sum(prev_percent) OVER (ORDER BY id),0) FROM (
      SELECT 
        id,
        percent,
        lag(percent) OVER () AS prev_percent
      FROM test
    ) x
) weighted_ids(id, weight_upper, weight_lower)
CROSS JOIN random_weight
WHERE rw BETWEEN weight_lower AND weight_upper;

不用说,表演太可怕了。它使用两组嵌套的窗口。我正在做的是:

  • 创建(id,percent,previous_percent)然后使用它创建两个用作范围括号的运行权重和;然后
  • 取一个随机值,将其缩放到权重范围,然后选择一个在目标范围内具有权重的值

答案 3 :(得分:2)

Branko接受的解决方案很棒(谢谢!)。但是,我想提供一种性能一样(根据我的测试),并且可能更易于可视化的替代方案。

回顾一下。最初的问题可能可以概括如下:

给出ID和相对权重的映射,创建一个查询,该查询返回映射中的随机ID,但概率与其相对权重成正比。

请注意强调相对重量,而不是百分比。正如Branko在回答中所指出的那样,使用相对权重将对包括百分数在内的任何内容都有效。

现在,考虑一些测试数据,我们将它们放在一个临时表中

CREATE TEMP TABLE test AS
SELECT * FROM (VALUES
    (1, 25),
    (2, 10),
    (3, 10),
    (4, 05)
) AS test(id, weight);

请注意,我使用的是比原始问题中的示例更复杂的示例,因为它不方便地加起来等于100,并且< em>相同的重量(20)被多次使用(emid 2和3) (重要的是要考虑的,您将在后面看到)。

我们要做的第一件事是将权重从0变为1,这不过是简单的归一化(权重/总和(权重)):

WITH p AS ( -- probability
    SELECT *,
        weight::NUMERIC / sum(weight) OVER () AS probability
    FROM test
),
cp AS ( -- cumulative probability
    SELECT *,
        sum(p.probability) OVER (
            ORDER BY probability DESC
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) AS cumprobability
    FROM p
)
SELECT
    cp.id,
    cp.weight,
    cp.probability,
    cp.cumprobability - cp.probability AS startprobability,
    cp.cumprobability AS endprobability
FROM cp
;

这将导致以下输出:

 id | weight | probability | startprobability | endprobability
----+--------+-------------+------------------+----------------
  1 |     25 |         0.5 |              0.0 |            0.5
  2 |     10 |         0.2 |              0.5 |            0.7
  3 |     10 |         0.2 |              0.7 |            0.9
  4 |      5 |         0.1 |              0.9 |            1.0

上面的查询被承认所做的工作比严格满足我们需要的要多,但是我发现它有助于以这种方式可视化相对概率,并且确实使选择id的最后一步变得微不足道:

SELECT id FROM (queryabove)
WHERE random() BETWEEN startprobability AND endprobability;

现在,我们将其与一个测试结合在一起,该测试确保该查询返回的数据具有预期的分布。我们将使用generate_series()生成一个随机数一百万次

WITH p AS ( -- probability
    SELECT *,
        weight::NUMERIC / sum(weight) OVER () AS probability
    FROM test
),
cp AS ( -- cumulative probability
    SELECT *,
        sum(p.probability) OVER (
            ORDER BY probability DESC
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) AS cumprobability
    FROM p
),
fp AS ( -- final probability
    SELECT
        cp.id,
        cp.weight,
        cp.probability,
        cp.cumprobability - cp.probability AS startprobability,
        cp.cumprobability AS endprobability
    FROM cp
)
SELECT *
FROM fp
CROSS JOIN (SELECT random() FROM generate_series(1, 1000000)) AS random(val)
WHERE random.val BETWEEN fp.startprobability AND fp.endprobability
;

这将导致类似于以下内容的输出:

 id | count  
----+--------
 1  | 499679 
 3  | 200652 
 2  | 199334 
 4  | 100335 

如您所见,这可以完美地跟踪预期分布。

性能

上面的查询性能很好。即使在我的普通机器上,PostgreSQL在WSL1实例中运行(恐怖!),执行速度也相对较快:

     count | time (ms)
-----------+----------
     1,000 |         7
    10,000 |        25
   100,000 |       210
 1,000,000 |      1950 

适应生成测试数据

在为单元/集成测试生成测试数据时,我经常使用上面查询的变体。这个想法是生成近似于追踪现实的概率分布的随机数据。

在这种情况下,我发现一次计算开始和结束分布并将结果存储在表中很有用:

CREATE TEMP TABLE test AS
WITH test(id, weight) AS (VALUES
    (1, 25),
    (2, 10),
    (3, 10),
    (4, 05)
),
p AS ( -- probability
    SELECT *, (weight::NUMERIC / sum(weight) OVER ()) AS probability
    FROM test
),
cp AS ( -- cumulative probability
    SELECT *,
        sum(p.probability) OVER (
            ORDER BY probability DESC
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) cumprobability
    FROM p
)
SELECT
    cp.id,
    cp.weight,
    cp.probability,
    cp.cumprobability - cp.probability AS startprobability,
    cp.cumprobability AS endprobability
FROM cp
;

然后我可以重复使用这些预先计算的概率,从而提高性能并简化使用。

我什至可以将其包装在一个函数中,只要我想获得一个随机ID,就可以调用该函数:

CREATE OR REPLACE FUNCTION getrandomid(p_random FLOAT8 = random())
RETURNS INT AS
$$
    SELECT id
    FROM test
    WHERE p_random BETWEEN startprobability AND endprobability
    ;
$$
LANGUAGE SQL STABLE STRICT

窗口功能框架

值得注意的是,以上技术正在使用具有非标准框架ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW的窗口函数。处理某些权重可能会重复这一事实是必要的,这就是为什么我首先选择具有重复权重的测试数据的原因!

答案 4 :(得分:1)

以下是您可以使用的内容:

select t1.id as id1
  , case when t2.id is null then 0 else t2.id end as id2
  , t1.percent as percent1
  , case when t2.percent is null then 0 else t2.percent end as percent2 
from "Test1" t1 
  left outer join "Test1" t2 on t1.id = t2.id + 1
where random() * 100 between t1.percent and 
  case when t2.percent is null then 0 else t2.percent end;

基本上执行左外连接,以便您有两列来应用between子句。

请注意,只有以正确的方式订购您的桌子才能使用它。

答案 5 :(得分:0)

基于布兰科·迪米特里耶维奇(Branko Dimitrijevic)的回答,我编写了此查询,使用分层窗口函数(与percent不同)使用ROLLUP的总和可能会或可能不会更快。

WITH random AS (SELECT random() AS random)
SELECT id FROM (
    SELECT id, percent,
    SUM(percent) OVER (ORDER BY id) AS rank,
    SUM(percent) OVER () * random AS roll
    FROM test CROSS JOIN random
) t WHERE roll <= rank LIMIT 1

如果排序不重要,则SUM(percent) OVER (ROWS UNBOUNDED PRECEDING) AS rank,可能更可取,因为它避免了必须先对数据进行排序。

我还尝试了Mechanic Wei的答案(as described in this paper, apparently),它的性能似乎很有希望,但是经过一些测试,the distribution appear to be off

SELECT id
FROM test
ORDER BY random() ^ (1.0/percent)
LIMIT 1