Djangoで、集計処理

Python Django

概要

Webアプリケーションにおいて、データの作成、一覧、更新、削除といったCRUD処理はよく使います。場合によってはそれらだけでなく、データの平均値を求めたり、何個のデータが紐づくか、といった集計処理も必要になります。

今回はその集計処理のいくつかのサンプルを紹介します。

サンプルモデルとして、以下のようなものを使います。本と、それへの評価モデルです。本屋さんのシステムかもしれませんね。

from django.db import models

SCORE_CHOICES = (
    (1, '★1'),
    (2, '★2'),
    (3, '★3'),
    (4, '★4'),
    (5, '★5'),
)


class Book(models.Model):
    """本"""
    title = models.CharField('タイトル', max_length=255)
    price = models.IntegerField('価格')

    def __str__(self):
        return '{} - {}'.format(self.title, self.price)


class Review(models.Model):
    """評価"""
    point = models.IntegerField('評価点', choices=SCORE_CHOICES)
    target = models.ForeignKey(Book, verbose_name='評価対象の本', on_delete=models.CASCADE)

    def __str__(self):
        # 'よくわかるPythonの本 - ★5' のように返す
        return '{} - {}'.format(self.target.title, self.get_point_display())

Bookは以下のようなデータがあります。
Book

Reviewは以下のように。
review

count()

Queryset.count()は、そのクエリで何件のデータが取得できたかを返します。

単純に、本とレビューの件数を取得してみましょう。

# 本の個数
>>> Book.objects.count()
3

# レビューの件数
>>> Review.objects.count()
7

count()は、クエリセットに対してのlen()と同様の結果を返します。

>>> len(Book.objects.all())
3

>>> len(Review.objects.all())
7

が、個数だけ知りたい場合はcount()のほうがパフォーマンス的に優れます。

「よくわかるPythonの本」のレビュー件数を取得してみます。

# targetのnameが、よくわかるPythonの本
>>> Review.objects.filter(target__title='よくわかるPythonの本').count()
3

2000円以上の書籍をカウントしてみます。

# 価格が2000以上。gteはgreater than equal の略
>>> Book.objects.filter(price__gte=2000).count()
2

aggregate()

aggregate()は、Querysetに対しての集計を行います。難しいと思うので、実際の例を見ていきましょう。

全ての書籍の、価格平均を求めます。

>>> from django.db.models import Avg
>>> Book.objects.aggregate(Avg('price'))
{'price__avg': 2166.6666666666665}

全ての書籍の価格で、いちばん高いものを求めます。

>>> from django.db.models import Max
>>> Book.objects.aggregate(Max('price'))
{'price__max': 3000}

一番安いものも同様に求めれます。

>>> from django.db.models import Min
>>> Book.objects.aggregate(Min('price'))
{'price__min': 1500}

これらを同時に求めることもできます。

>>> from django.db.models import Avg, Max, Min
>>> Book.objects.aggregate(Max('price'), Min('price'), Avg('price'))
{'price__max': 3000, 'price__min': 1500, 'price__avg': 2166.6666666666665}

結果は辞書で帰りますが、その名前はフィールド名__集計関数というルールで作成されます。その名前を変えたい場合は、以下のようにキーワード引数名として指定します。

>>> from django.db.models import Min
>>> Book.objects.aggregate(yasui=Min('price'))
{'yasui': 1500}

全てのレビューの平均なんかも同様に求めれますね。

>>> from django.db.models import Avg
>>> Review.objects.aggregate(Avg('point'))
{'point__avg': 3.5714285714285716}

filter()等と一緒にも使えますが、aggregate()は最後に呼び出す必要があります。2000円以上の書籍の、評価の平均値を求めてみます。

>>> Review.objects.filter(target__price__gte=2000).aggregate(Avg('point'))
{'point__avg': 3.6}

annotate()

annotate()は、QuerySet の各アイテムに対する集計を生成します。ForeignKeyManyToManyField等、他のモデルと何らかの関係で紐づいていて、それらに関する集計に使うことになります。

イメージとしては、以下のような感じです。

for book in Book.objects.annotate...:
    # ここで、各アイテム...書籍ごとに取得した集計データが取れる
    # その書籍に紐づいたモデル(今回ならレビュー)の件数や平均、最小、最大など

実際に試してみましょう。各書籍に、何件のレビューがついているかです。

>>> from django.db.models import Count
>>> for book in Book.objects.annotate(Count('review')):
...     '書籍名:{} - レビュー数:{}'.format(book.title, book.review__count)
...
'書籍名:よくわかるPythonの本 - レビュー数:3'
'書籍名:普通のPythonの本 - レビュー数:2'
'書籍名:わからないPythonの本 - レビュー数:2'

for book in Book.objects.annotate(Count('review')):ですが、for book in Book.objects.all()と似ていますが、bookにreview_countという属性が追加され、そこに紐づいたレビューの数が格納されています。

aggregate同様フィールド名__集計関数という属性名で、キーワード引数名として指定するとその名前が使えます。

# reviewsという名前にする
>>> for book in Book.objects.annotate(reviews=Count('review')):
...     '書籍名:{} - 評価数:{}'.format(book.title, book.reviews)

紐づいたレビューのうち、一番高い評価も取得します。

>>> for book in Book.objects.annotate(max_review=Max('review__point')):
...     '書籍名:{} - 最高評価:{}'.format(book.title, book.max_review)
...
'書籍名:よくわかるPythonの本 - 最高評価:5'
'書籍名:普通のPythonの本 - 最高評価:4'
'書籍名:わからないPythonの本 - 最高評価:2'

一番低い評価を取得します。

>>> for book in Book.objects.annotate(min_review=Min('review__point')):
...     '書籍名:{} - 最低評価:{}'.format(book.title, book.min_review)
...
'書籍名:よくわかるPythonの本 - 最低評価:4'
'書籍名:普通のPythonの本 - 最低評価:3'
'書籍名:わからないPythonの本 - 最低評価:2'

そして、平均評価です。

>>> for book in Book.objects.annotate(avg_review=Avg('review__point')):
...     '書籍名:{} - 平均評価:{}'.format(book.title, book.avg_review)
...
'書籍名:よくわかるPythonの本 - 平均評価:4.666666666666667'
'書籍名:普通のPythonの本 - 平均評価:3.5'
'書籍名:わからないPythonの本 - 平均評価:2.0'

また、追加された属性名を使ってorder_by()として並び替えるのも有用です。レビュー数の多い本から表示するようにしてみましょう。

>>> for book in Book.objects.annotate(reviews=Count('review')).order_by('-reviews'):
...     '書籍名:{} - 評価数:{}'.format(book.title, book.reviews)
...
'書籍名:よくわかるPythonの本 - 評価数:3'
'書籍名:普通のPythonの本 - 評価数:2'
'書籍名:わからないPythonの本 - 評価数:2'

ブログでよくやるのは、カテゴリ名を表示しつつそのカテゴリに属する記事数も一緒に表示したりします。Python(100記事) みたいな感じですね。これもannotate()でやることができます。order_byで記事が多いカテゴリから順に表示する、とかもよくやる処理ですね。

Djangoで、カスタムマネージャーを使うでは、それを初期クエリの段階で行う方法を紹介しています。

Relation Posts

Comment

記事にコメントする

Django5ヶ月目

いつも大変有益な情報をありがとうございます。 本記事でのannotateの部分に関してご質問です。

例えば、下記のように「コメントの数が多い順に紐づく記事を並び替えたい」といった場合に、 「annotateの結果が0件」のデータが取得できずに困っています。 results = self.model.objects.all().annotate(Count('comment')).order_by('-comment__count')

上記の対策として、naritoさんはどのような方法で対策されていますでしょうか?

お手すきでご確認いただいただけますと幸いです。 よろしくお願いいたします!

返信する

なりと

例えば、下記のように「コメントの数が多い順に紐づく記事を並び替えたい」といった場合に、 「annotateの結果が0件」のデータが取得できずに困っています。 results = self.model.objects.all().annotate(Count('comment')).order_by('-comment__count')

確認なのですが、単純にannotateだけならば結果が0件でも表示されませんか。もしfilter()も利用されている場合はその問題が起きると思います。

やりたい処理によってちょっと変わるのですが、例として3日以内のコメントの数が多い順に紐づく記事を並び替えたい場合ならば次のように書けます。

import datetime
from django.db.models import Count, Q
from django.utils import timezone
from app.models import Post
...
...
three_days_ago = timezone.now() - datetime.timedelta(days=3)
result = Post.objects.annotate(num_comment=Count('comment', filter=Q(comment__created_at__gte=three_days_ago))).order_by('-num_comment')

Django5ヶ月目

ご回答いただきありがとうございます。

おっしゃる通り、filterの処理も含めて実装しておりましたので、 filterを外すと0件も表示されるようになりました!

また、並び替えの例も挙げていただき、大変勉強になります。 引き続き進めて参ります。

Django大好き

naritoさん、こんにちは。 いつも大変勉強になる情報をありがとうございます。

集計についてご質問があります。 現在、お店の情報に口コミをForeign Keyで持たせ、お店情報一覧に口コミに平均値を集計して表示させようと思っています。 単にこれだけであれば簡単なのですが、口コミにはステータスを持たせており、「public」と「close」を持たせています。(論理削除用です) この「publish」のみを集計したいのですが、実際にはcloseも一緒に集計されてしまいます。下記、views.pyのlistviewのquerysetです。

def get_queryset(self, **kwargs): results = self.model.objects.all.annotate(Avg('restaurant_review__reviewRating')).order_by('-numbering')

    return results

ステータスでうまくフィルタをかける方法などがあればご教示いただけますと幸いです。 よろしくお願いいたします。

返信する

なりと

次のようにしてみると、どうですか。

self.model.objects.filter(public=True).annotate(Avg('restaurant_review__reviewRating')).order_by('-numbering')

Django大好き

ご回答ありがとうございます。

言葉が足りておらず申し訳ないです。。 お店のモデルにも「public」のBooleanのフィールドを作っており、 アドバイスいただいた書き方ですとお店そのものクエリセットが絞り込まれてしまいます。。

やりたいことは、あくまでお店に紐づいた「口コミ」モデルにおいて、 choicesで「public」が選ばれている口コミデータのみを集計したい(「close」が選ばれている場合はその口コミは公開されないため)といった感じです。

上記引き続きアドバイスいただけましたら幸いです。 よろしくお願いします!

なりと

その場合は、次のようになると思います。

from django.db.models import Avg, Q
...
...
self.model.objects.annotate(Avg('restaurant_review__reviewRating'), filter=Q(restaurant_review__public=True)).order_by('-numbering')

Django大好き

ご回答ありがとうございます! 下記エラーが出てしまったのですが、どこに問題がありそうでしょうか。。? 'WhereNode' object has no attribute 'output_field'

ちなみに正確には下記のようにQuerysetをカスタマイズしています。

def get_queryset(self, **kwargs):
        # 口コミの平均値を取得
        results = self.model.objects.filter(published=True).annotate(Avg('restaurant_review__reviewRating'), filter=Q(restaurant_review__status='published')).order_by('-numbering')

        return results

self.model(つまりrestaurant)はpublishedというフィールドのBooleanを持たせていて、restaurant_reviewはstatusというフィールドにpublishedとcloseという選択肢をchoicesで持たせています。

お手すきでご確認いただけますと幸いです。 よろしくお願いします。

なりと

models.pyの中身を全て教えてください。

Django大好き

ご確認いただき本当にありがとうございます。 メールにてお送りさせていただきました。

お時間のあるときにご確認いただけますと幸いです。 よろしくお願いいたします。

なりと

.filter(published=True).annotate(Avg('restaurant_review__reviewRating', filter=Q(restaurant_review__status='published'))).order_by('-numbering')

でいけると思います。上で紹介したコードは、filter引数の場所を少し間違えていました。

mimi

いつも拝見さてもらっています。 aggregate()の文法について勉強していて理解できないポイントがありまして質問させていただきたいです。

class ShopingCart(models.Model):
    user = models.OneToOneField(
        User,
        verbose_name = 'ユーザー',
        related_name = 'cart',
        on_delete=models.CASCADE
    )

    @property
    def total_price(self):
        return self.cart_items.all().aggregate(total = Sum(F('product__price') * F('amount')))['total']

class ShopingCartItem(models.Model):
    cart = models.ForeignKey(
        ShopingCart,
        verbose_name = 'ショッピングカート',
        related_name= 'cart_items',
        on_delete=models.CASCADE
    )
    product = models.ForeignKey(
        Product,
        verbose_name = '商品',
        on_delete=models.CASCADE
    )
    amount = models.IntegerField(
        verbose_name = '数量'
    )

この様なコードがありまして、shopingcartクラス内のtotal_price関数とitem_count関数の最後 ['amount']や['total']なぜ必要なのでしょうか? 単純に

def total_price(self):
        return self.cart_items.all().aggregate(total = Sum(F('product__price') * F('amount')))

ではいけないのでしょうか?

返信する

なりと

aggregate()の結果は、辞書です。今回の例で言えば、次のような辞書になります。

{'total': 1100}

なので、これを取り出すために['total']がが必要なだけです。

何故辞書なのかというと、幾つかの集計を組み合わせる場合がある為です。例えば、Productの平均価格も求めるとすると、次のようなコードで表現できます。

return self.cart_items.all().aggregate(total=Sum(F('product__price') * F('amount')), avg=Avg('product__price'))

この結果の辞書は、totalの他にavgという要素も持ちます。

{'total': 1100, 'avg': 200.0}