在Django中实现热门算法

8 投票
4 回答
4826 浏览
提问于 2025-04-15 17:23

我正在创建一个类似于reddit和hacker news的网站,里面有链接和投票的数据库。我正在实现hacker news的热门算法,进展得还不错,但在收集这些链接并展示它们时遇到了一些问题。这个算法其实很简单:

Y Combinator's Hacker News:
Popularity = (p - 1) / (t + 2)^1.5`

Votes divided by age factor.
Where`

p : votes (points) from users.
t : time since submission in hours.

p is subtracted by 1 to negate submitter's vote.
Age factor is (time since submission in hours plus two) to the power of 1.5.factor is (time since submission in hours plus two) to the power of 1.5.

我在另一个地方问过一个很相似的问题,关于Django中的复杂排序,但这次我没有考虑太多的选项,而是选择了一个方案并尝试让它工作,因为我以前用PHP/MySQL时就是这样做的。不过现在我知道Django的做法和我想象的很不一样。

我的模型大概是这样的:

class Link(models.Model):
category = models.ForeignKey(Category)
user = models.ForeignKey(User)
created = models.DateTimeField(auto_now_add = True)
modified = models.DateTimeField(auto_now = True)
fame = models.PositiveIntegerField(default = 1)
title = models.CharField(max_length = 256)
url = models.URLField(max_length = 2048)

def __unicode__(self):
    return self.title

class Vote(models.Model):
link = models.ForeignKey(Link)
user = models.ForeignKey(User)
created = models.DateTimeField(auto_now_add = True)
modified = models.DateTimeField(auto_now = True)
karma_delta = models.SmallIntegerField()

def __unicode__(self):
    return str(self.karma_delta)

然后是我的视图:

def index(request):
popular_links = Link.objects.select_related().annotate(karma_total = Sum('vote__karma_delta'))
return render_to_response('links/index.html', {'links': popular_links})

根据我之前的问题,我想用排序功能来实现这个算法。那里的一个回答似乎认为我应该把算法放在选择和排序中。我打算对这些结果进行分页,所以我觉得在Python中进行排序可能会抓取到所有的数据,这样不太合适。有没有什么建议可以让我更高效地做到这一点呢?

编辑

现在这个还没成功,但我觉得这是朝着正确方向迈出的一步:

from django.shortcuts import render_to_response
from linkett.apps.links.models import *

def index(request):
popular_links = Link.objects.select_related()
popular_links = popular_links.extra(
    select = {
        'karma_total': 'SUM(vote.karma_delta)',
        'popularity': '(karma_total - 1) / POW(2, 1.5)',
    },
    order_by = ['-popularity']
)
return render_to_response('links/index.html', {'links': popular_links})

不过出现了这样的错误:

Caught an exception while rendering: column "karma_total" does not exist
LINE 1: SELECT ((karma_total - 1) / POW(2, 1.5)) AS "popularity", (S...

编辑 2

这个错误更好了吗?

TemplateSyntaxError: Caught an exception while rendering: missing FROM-clause entry for table "vote"
LINE 1: SELECT ((vote.karma_total - 1) / POW(2, 1.5)) AS "popularity...

我的index.html文件很简单:

{% block content %}

{% for link in links %}
 
  
   karma-up
   {{ link.karma_total }}
   karma-down
  
  {{ link.title }}
  Posted by {{ link.user }} to {{ link.category }} at {{ link.created }}
 
{% empty %}
 No Links
{% endfor %}

{% endblock content %}

编辑 3

离成功又近了一步!这些回答都很棒,但我现在专注于一个特别的方案,因为我觉得它最适合我的情况。

from django.db.models import Sum
from django.shortcuts import render_to_response
from linkett.apps.links.models import *

def index(request): popular_links = Link.objects.select_related().extra( select = { 'popularity': '(SUM(links_vote.karma_delta) - 1) / POW(2, 1.5)', }, tables = ['links_link', 'links_vote'], order_by = ['-popularity'], ) return render_to_response('links/test.html', {'links': popular_links})

运行这个代码时,我遇到了一个错误,提示我缺少分组的值。具体来说:

TemplateSyntaxError at /
Caught an exception while rendering: column "links_link.id" must appear in the GROUP BY clause or be used in an aggregate function
LINE 1: ...karma_delta) - 1) / POW(2, 1.5)) AS "popularity", "links_lin...

我不明白为什么我的links_link.id不在我的分组中,但我不知道该如何修改我的分组,通常Django会处理这些事情。

4 个回答

4
popular_links = Link.objects.select_related()
popular_links = popular_links.extra(
    select = {
        'karma_total': 'SUM(vote.karma_delta)',
        'popularity': '(karma_total - 1) / POW(2, 1.5)'
    },
    order_by = ['-popularity']
)

或者你可以选择一个合理的数字,使用Python以任何你喜欢的方式对这些数字进行排序。如果这些结果是静态的,也就是所有用户看到的内容都一样,你可以把它们缓存起来,设置缓存的过期时间为一分钟左右。

不过,如果你的数据是动态变化的,使用extra会更适合处理分页结果。

4

看起来你可以重写一下 Vote 类里的 save 方法,让它在保存的时候更新对应的 Link 对象。像这样应该就能很好地工作:

from datetime import datetime, timedelta

class Link(models.Model):
 category = models.ForeignKey(Category)
 user = models.ForeignKey(User)
 created = models.DateTimeField(auto_now_add = True)
 modified = models.DateTimeField(auto_now = True)
 fame = models.PositiveIntegerField(default = 1)
 title = models.CharField(max_length = 256)
 url = models.URLField(max_length = 2048)

 #a field to keep the most recently calculated popularity
 popularity = models.FloatField(default = None)

 def CalculatePopularity(self):
  """
  Add a shorcut to make life easier ... this is used by the overloaded save() method and 
  can be used in a management function to do a mass-update periodically
  """
  ts = datetime.now()-self.created
  th = ts.seconds/60/60
  self.popularity = (self.user_set.count()-1)/((th+2)**1.5)

 def save(self, *args, **kwargs):
  """
  Modify the save function to calculate the popularity
  """
  self.CalculatePopularity()
  super(Link, self).save(*args, **kwargs)


 def __unicode__(self):
     return self.title

class Vote(models.Model):
 link = models.ForeignKey(Link)
 user = models.ForeignKey(User)
 created = models.DateTimeField(auto_now_add = True)
 modified = models.DateTimeField(auto_now = True)
 karma_delta = models.SmallIntegerField()

 def save(self, *args, **kwargs):
  """
  Modify the save function to calculate the popularity of the Link object
  """
  self.link.CalculatePopularity()
  super(Vote, self).save(*args, **kwargs)

 def __unicode__(self):
     return str(self.karma_delta)

这样每次你调用 link_o.save() 或 vote_o.save() 的时候,它都会重新计算受欢迎程度。不过你得小心,因为如果你用 Link.objects.all().update('更新某些东西') 这样的方式,它就不会调用我们重写的 save() 方法。所以我在用这种方法的时候,会创建一个管理命令来更新所有对象,确保它们不会太过时。像这样就能很好地工作:

from itertools import imap
imap(lambda x:x.CalculatePopularity(), Link.objects.all().select_related().iterator())

这样的话,它一次只会把一个 Link 对象加载到内存中……所以如果你的数据库很大,就不会出现内存错误。

现在要进行排名,你只需要:

Link.objects.all().order_by('-popularity')

这样会非常快,因为你所有的 Link 项目已经计算过受欢迎程度了。

10

在Hacker News上,只有最新的210个故事和最受欢迎的210个故事会分页显示(总共7页,每页30个故事)。我猜这个限制的原因(至少部分原因)就是为了避免某些问题。

为什么不放弃那些复杂的SQL查询,直接保持一个热门故事的列表呢?一旦你建立了这210个热门故事的列表,之后只需要在有新投票进来的时候调整一下顺序,因为这些故事的相对位置是会随着时间保持不变的。当有新投票时,你只需要关注那个被投票的故事。

如果被投票的故事不在列表里,就先计算一下这个故事的得分,再加上列表中得分最低的那个故事。如果这个新故事的得分更低,那就没事了。如果更高,就再计算一下倒数第二个得分最低的故事(第209个故事)的得分,然后再比较。一直这样进行,直到找到一个得分更高的故事,然后把新投票的故事放在那个故事的下面,除非它直接变成了第一名。

这种方法的好处是,你只需要关注一小部分故事,就能搞定热门故事的列表。在最糟糕的情况下,你最多只需要计算211个故事的排名。所以这个方法非常高效,除非你需要从已有的数据中建立这个列表——但这只是一次性的麻烦,假如你把列表缓存起来就没问题了。

至于反对票(downvotes),那是另一个问题,不过我现在只能投赞成票(至少在我的等级下是这样)。

撰写回答