在MongoDB聚合框架中计算中位数

31

使用MongoDB聚合框架有没有计算中位数的方法?


据我所知,没有$median这样的东西,因此您可能需要使用map-reduce来实现这一点。 - hgoebl
1
有一个开放的功能请求,希望添加对 $median 聚合器的支持。请在 MongoDB 问题跟踪器中点赞/关注 SERVER-4929 - Stennie
7个回答

42

由于计算中位数需要对整个数据集进行排序,或使用递归,并且递归深度也与数据集大小成比例,因此在一般情况下计算中位数有些棘手。这可能是为什么许多数据库没有开箱即用的中位数运算符的原因(MySQL 也没有)。

计算中位数最简单的方法是使用以下两个语句(假设要计算中位数的属性名为 a,想要在集合 coll 的所有文档中计算它):

count = db.coll.count();
db.coll.find().sort( {"a":1} ).skip(count / 2 - 1).limit(1);

这相当于人们建议MySQL的方法


10
我知道不能只写评论来表示感谢,但这真的很美 :) - fguillen
2
警告:这是轻代码,但服务器负载较重。 - coiso

12

通过聚合框架可以一次性完成此操作。

排序 => 将排序后的值放入数组 => 获取数组大小 => 将大小除以二 => 获取除法的整数值(中位数左侧) => 在左侧加1(右侧) => 获取左侧和右侧的数组元素 => 两个元素的平均值

以下是使用Spring java mongoTemplate的示例:

该模型是一个作者登录名(“所有者”)列表,目标是通过用户获取书籍中位数:

        GroupOperation countByBookOwner = group("owner").count().as("nbBooks");

    SortOperation sortByCount = sort(Direction.ASC, "nbBooks");

    GroupOperation putInArray = group().push("nbBooks").as("nbBooksArray");

    ProjectionOperation getSizeOfArray = project("nbBooksArray").and("nbBooksArray").size().as("size");

    ProjectionOperation divideSizeByTwo = project("nbBooksArray").and("size").divide(2).as("middleFloat");

    ProjectionOperation getIntValueOfDivisionForBornLeft = project("middleFloat", "nbBooksArray").and("middleFloat")
            .project("trunc").as("beginMiddle");

    ProjectionOperation add1ToBornLeftToGetBornRight = project("beginMiddle", "middleFloat", "nbBooksArray")
            .and("beginMiddle").project("add", 1).as("endMiddle");

    ProjectionOperation arrayElementAt = project("beginMiddle", "endMiddle", "middleFloat", "nbBooksArray")
            .and("nbBooksArray").project("arrayElemAt", "$beginMiddle").as("beginValue").and("nbBooksArray")
            .project("arrayElemAt", "$endMiddle").as("endValue");

    ProjectionOperation averageForMedian = project("beginMiddle", "endMiddle", "middleFloat", "nbBooksArray",
            "beginValue", "endValue").and("beginValue").project("avg", "$endValue").as("median");

    Aggregation aggregation = newAggregation(countByBookOwner, sortByCount, putInArray, getSizeOfArray,
            divideSizeByTwo, getIntValueOfDivisionForBornLeft, add1ToBornLeftToGetBornRight, arrayElementAt,
            averageForMedian);

    long time = System.currentTimeMillis();
    AggregationResults<MedianContainer> groupResults = mongoTemplate.aggregate(aggregation, "book",
            MedianContainer.class);

这里是聚合的结果:

{
"aggregate": "book" ,
"pipeline": [
    {
        "$group": {
            "_id": "$owner" ,
            "nbBooks": {
                "$sum": 1
            }
        }
    } , {
        "$sort": {
            "nbBooks": 1
        }
    } , {
        "$group": {
            "_id": null  ,
            "nbBooksArray": {
                "$push": "$nbBooks"
            }
        }
    } , {
        "$project": {
            "nbBooksArray": 1 ,
            "size": {
                "$size": ["$nbBooksArray"]
            }
        }
    } , {
        "$project": {
            "nbBooksArray": 1 ,
            "middleFloat": {
                "$divide": ["$size" , 2]
            }
        }
    } , {
        "$project": {
            "middleFloat": 1 ,
            "nbBooksArray": 1 ,
            "beginMiddle": {
                "$trunc": ["$middleFloat"]
            }
        }
    } , {
        "$project": {
            "beginMiddle": 1 ,
            "middleFloat": 1 ,
            "nbBooksArray": 1 ,
            "endMiddle": {
                "$add": ["$beginMiddle" , 1]
            }
        }
    } , {
        "$project": {
            "beginMiddle": 1 ,
            "endMiddle": 1 ,
            "middleFloat": 1 ,
            "nbBooksArray": 1 ,
            "beginValue": {
                "$arrayElemAt": ["$nbBooksArray" , "$beginMiddle"]
            } ,
            "endValue": {
                "$arrayElemAt": ["$nbBooksArray" , "$endMiddle"]
            }
        }
    } , {
        "$project": {
            "beginMiddle": 1 ,
            "endMiddle": 1 ,
            "middleFloat": 1 ,
            "nbBooksArray": 1 ,
            "beginValue": 1 ,
            "endValue": 1 ,
            "median": {
                "$avg": ["$beginValue" , "$endValue"]
            }
        }
    }
]

6
不确定这是否有效,只是为了表达支持而点赞。 - Marcel Falliere

8
从Mongo 4.4开始,$group阶段有一个新的聚合操作符$accumulator,允许通过JavaScript用户定义函数在文档分组时进行自定义累加。
因此,为了找到中位数:
// { "a" : 25, "b" : 12 }
// { "a" : 89, "b" : 7  }
// { "a" : 25, "b" : 17 }
// { "a" : 25, "b" : 24 }
// { "a" : 89, "b" : 15 }
db.collection.aggregate([
  { $group: {
    _id: "$a",
    median: {
      $accumulator: {
        accumulateArgs: ["$b"],
        init: function() { return []; },
        accumulate: function(bs, b) { return bs.concat(b); },
        merge: function(bs1, bs2) { return bs1.concat(bs2); },
        finalize: function(bs) {
          bs.sort(function(a, b) { return a - b });
          var mid = bs.length / 2;
          return mid % 1 ? bs[mid - 0.5] : (bs[mid - 1] + bs[mid]) / 2;
        },
        lang: "js"
      }
    }
  }}
])
// { "_id" : 25, "median" : 17 }
// { "_id" : 89, "median" : 11 }

累加器:

  • 在字段b上进行累加(accumulateArgs)
  • 初始化为空数组(init)
  • b个项累加到数组中(accumulatemerge)
  • 最后对b个项执行中位数计算(finalize)

嗨,这是一个非常好的解决问题的方法。但我正在开发一个需要相同功能的Java项目。你知道在哪里可以找到相关文档吗?谢谢。 - qangdev

5

尽管maxiplay的答案并不准确,但它确实指导了我正确的方向。给定解决方案的问题在于它仅适用于记录数为偶数的情况。因为对于记录数为奇数的情况,只需要取中点处的值而无需计算平均值。

这是我让它正常运行的方法。

db.collection.aggregate([
{ "$match": { "processingStatus": "Completed" } },
{ "$sort": { "value": 1 } },
{ 
    "$group": {
        "_id": "$userId",
        "valueArray": {
            "$push": "$value"
        }
    } 
},
{
    "$project": {
        "_id": 0,
        "userId": "$_id",
        "valueArray": 1,
        "size": { "$size": ["$valueArray"] }
    }
},
{
    "$project": {
        "userId": 1,
        "valueArray": 1,
        "isEvenLength": { "$eq": [{ "$mod": ["$size", 2] }, 0 ] },
        "middlePoint": { "$trunc": { "$divide": ["$size", 2] } }
    }
},
{
    "$project": {
        "userId": 1,
        "valueArray": 1,
        "isEvenLength": 1,
        "middlePoint": 1,
        "beginMiddle": { "$subtract": [ "$middlePoint", 1] },
        "endMiddle": "$middlePoint"
    }
},
{
    "$project": {
        "userId": 1,
        "valueArray": 1,
        "middlePoint": 1,
        "beginMiddle": 1,
        "beginValue": { "$arrayElemAt": ["$valueArray", "$beginMiddle"] },
        "endValue": { "$arrayElemAt": ["$valueArray", "$endMiddle"] },
        "isEvenLength": 1
    }
},
{
    "$project": {
        "userId": 1,
        "valueArray": 1,
        "middlePoint": 1,
        "beginMiddle": 1,
        "beginValue": 1,
        "endValue": 1,
        "middleSum": { "$add": ["$beginValue", "$endValue"] },
        "isEvenLength": 1
    }
},
{
    "$project": {
        "userId": 1,
        "valueArray": 1,
        "median": { 
            "$cond": { 
                if: "$isEvenLength", 
                then: { "$divide": ["$middleSum", 2] },
                else:  { "$arrayElemAt": ["$valueArray", "$middlePoint"] }
            } 
        }
    }
}
])

在管道中没有 stepsArray,因此结果始终为 null。 - taicoding
1
管道出现问题,$sort在$group之后,应该放在之前。如果您仍然得到空结果,请告诉我。 - Taher
2
@taicoding 如果你将 $stepsArray 改为 $valueArray,这个查询就能用了。 - jesse_english
使用 $let 可以使得使用单个 $project 成为可能。 - Amit Beckenstein

2
聚合框架默认不支持中位数计算。因此,您需要自己编写代码来实现。
我建议您在应用程序级别上完成这项任务。通过使用正常的find()函数检索所有文档,排序结果集(可以在数据库中使用游标的.sort()函数进行排序,也可以在应用程序中进行排序-由您决定),然后获取元素大小/ 2即可。
当您真正想在数据库级别上完成时,可以使用map-reduce方法。 map函数将发出键和带有单个值的数组,这是您要获取中位数的值。 reduce函数只需连接它接收到的结果数组,使每个键都具有包含所有值的数组。 最终函数将计算该数组的中位数,再次通过对数组进行排序并获取元素号码size / 2来实现。

0

我对这个问题的解决方案与Taher's answer相似,但使用了较少的$project阶段。

// { "value" : 1 }
// { "value" : 2  }
// { "value" : 4 }
// { "value" : 5 }
db.median_values.aggregate([
  // Sort the values
  { $sort: { value: 1 } },
  // Get an array of all the values
  { $group: { _id: null, valuesArray: { $push: "$value" } } },
  // Get if the array has an even or odd number of elements
  {
    $project: {
      _id: 0,
      valuesArray: 1,
      isEven: { $eq: [{ $mod: [{ $size: "$valuesArray" }, 2] }, 0] },
      dividedByTwoIndex: { $divide: [{ $size: "$valuesArray" }, 2] },
    },
  },
  // Get the left value and right value if the array has an even or odd number of elements
  {
    $project: {
      _id: 0,
      left: {
        $cond: {
          if: "$isEven",
          then: {
            $arrayElemAt: [
              "$valuesArray",
              { $subtract: ["$dividedByTwoIndex", 1] },
            ],
          },
          else: {
            $arrayElemAt: ["$valuesArray", { $floor: "$dividedByTwoIndex" }],
          },
        },
      },
      right: {
        $cond: {
          if: "$isEven",
          then: {
            $arrayElemAt: ["$valuesArray", "$dividedByTwoIndex"],
          },
          else: {
            $arrayElemAt: ["$valuesArray", { $floor: "$dividedByTwoIndex" }],
          },
        },
      },
    },
  },

  // Compute the median value
  { $project: { median: { $avg: ["$left", "$right"] } } },
]);

// Output:
// { "median" : 3 }

0
自从mongoDB版本7.0以来,新增了一个$median累加器。 例如:
db.collection.aggregate([
  {$group: {
      _id: null,
      median: {
        $median: {
          input: "$rating",
          method: "approximate"
        }
      }
    }
  }
])

看看它在7.0版本以来的游乐场中是如何工作的


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