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

Posted

技术标签:

【中文标题】从具有加权行概率的 PostgreSQL 表中选择随机行【英文标题】:Select random row from a PostgreSQL table with weighted row probabilities 【发布时间】:2012-10-13 23:07:42 【问题描述】:

示例输入:

从测试中选择*; 编号 |百分 ----+------------ 1 | 50 2 | 35 3 | 15 (3 行)

您将如何编写这样的查询,平均 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

有什么想法吗?

【问题讨论】:

错误结果是什么意思? @Clodoaldo,在运行上述查询 10k 次后,我得到下一个结果(要计数的位置):1=6293, 2=3302, 3=405,但我希望它们几乎像那样: 1=5000, 2=3500, 3=1500。 @OlegGolovanov 好的,所以查询有效,但分布错误。 非常有趣的问题。谢谢你的问题。将来值得更具体地说明为什么某些东西“不起作用”或有“错误”的结果,但除此之外......好的大脑食物,谢谢。 【参考方案1】:

您提出的查询似乎有效;见this SQLFiddle demo。但是,它会产生错误的分布;见下文。

为了防止 PostgreSQL 优化子查询,我将它包装在 VOLATILE SQL 函数中。 PostgreSQL 无法知道您是否打算为外部查询的每一行运行一次子查询,因此如果您不强制它变为 volatile,它只会执行一次。另一种可能性——尽管查询规划器将来可能会优化——是让它看起来是一个相关的子查询,就像这个使用永远为真 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 是 4%,而应该是 15%。 @digitaljoel 好点。我假设他们有用的“不工作”是不相关子查询优化在一组中产生相同结果的问题,而不是意外分布。唔。 试图在大脑中挖掘旧的概率讲座 @digitaljoel 知道了;问题在于随机数的多重采样。【参考方案2】:

这里有一些东西供你玩:

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 子句。

请注意,只有以正确的方式订购餐桌时,它才会起作用。

【讨论】:

您知道我想到如果您在表中包含一个“牺牲”行 (0,0),那么您可以简单地进行内部连接,并删除讨厌的 case 语句。这将大大简化查询。【参考方案3】:

这应该可以解决问题:

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)来保证随机数只计算一次。

顺便说一句,SELECT SUM(percent) FROM YOUR_TABLE 允许您在 percent 中拥有任何权重 - 它们并不一定是百分比(即加起来为 100)。

[SQL Fiddle]

【讨论】:

... 但事实并非如此;它会产生一个不同的错误分布。见sqlfiddle.com/#!12/b67b6/2 @CraigRinger 是的,问题可能在于随机数的重复生成。通过将其移动到公用表表达式,它只生成一次,给出much nicer result。 这是一个比我写的更好、更快的查询;我们采用了相同的方法来解决问题,但您的解决方案比我使用嵌套窗口计算加权范围要高效得多。 这可能不需要说明,但是如果您事先知道概率的总和是多少,则可以避免 CTE。例如,如果所有行的百分比列加起来总是 100%,那么我们可以取出 CTE,移除交叉连接,并将 where S >= R 替换为 `where S >= random() * 100 @JohnFawcett 不幸的是,这不起作用,因为random() 会为每一行进行评估(并产生不同的值)。但是我们希望它产生一个值,然后根据该值选择一行。请查看答案的历史 - 我第一次弄错了,正是因为多代随机值,直到 Craig Ringer 发现了问题。【参考方案4】:

ORDER BY random() ^ (1.0 / p)

来自 Efraimidis 和 Spirakis 描述的算法。

【讨论】:

【参考方案5】:

根据 Branko Dimitrijevic 的回答,我编写了这个查询,通过使用分层窗口函数(与 ROLLUP 不同)使用 percent 的总和可能会或可能不会更快。

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, 可能更可取,因为它避免了必须先对数据进行排序。

我也尝试了魏技师的回答(as described in this paper, apparently),在性能方面看起来很有希望,但经过一些测试,the distribution appear to be off:

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

【讨论】:

【参考方案6】:

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) 被多次使用(对于 id 2 和 3),这一点很重要,稍后您会看到。

我们要做的第一件事就是把权重变成从0到1的概率,无非就是简单的归一化(weight / sum(weights)):

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的窗口函数。这是处理某些权重可能重复的事实所必需的,这就是为什么我首先选择具有重复权重的测试数据!

【讨论】:

以上是关于从具有加权行概率的 PostgreSQL 表中选择随机行的主要内容,如果未能解决你的问题,请参考以下文章

Postgresql从具有不同列数的2个表中选择多条记录

使用 JQuery 从具有 JSON 值的表中选择行

从每个 id 具有特定值的表中选择行

如何仅从 PostgreSQL 的内部联接中不存在 id 的表中选择行?

从元素具有权重的列表中选择 k 个随机元素

从具有 SQLite 中的公共字段的数据库表中选择行