Every site that shows a list of “popular” things (stories, links, posts, products, tags) has to decide what popular means. The simplest answer is to count: the item with the most likes, up-votes, clicks or shares is on top.
But counts only grow. An item that collected a thousand likes last month stays ahead of one that fifty people liked in the last hour, and a list ranked by counts fills up with yesterday’s news. What readers usually want is what’s popular now.
TimeScore fixes this by letting every like fade with age. A like is worth one point when it happens, half a point one half-life later, a quarter after two half-lives, and so on. An item’s score is the sum of its faded likes, so the top of the list is whatever has been getting attention lately. And each item’s score is kept as a single number that never has to be updated as time passes.
Try it
Here are three posts and a clock that runs at 3600 times real time (one second is one hour). Likes fade with a half-life of eight hours. When the clock starts, Red has the most likes, but they’re two days old. Blue got its likes over the past half day, and Green just got three in the last two hours.
Click Like under a post and watch its line jump. Stop clicking and it falls by half every eight hours. At the start, Red has the most likes, and yet it ranks last.
- Likes
- 0
- TimeScore
- 0.00
- Likes
- 0
- TimeScore
- 0.00
- Likes
- 0
- TimeScore
- 0.00
- Ranked by TimeScore
- Ranked by likes
How it works
The TimeScore
Say an item gets events (likes, here) at times , each with a value (1 for a like). With a half-life , its TimeScore at time is the sum of those values, each halved for every half-life since it happened:
The trouble is that this changes every moment, even when nothing happens. To keep a list sorted by it, you’d have to recompute every item’s score as the clock moves, or at least every time you show the list.
The time-invariant TimeScore
But every item’s score fades at the same rate. Between events, all the scores shrink by the same factor, so their order never changes. Only a new event can move an item up the list.
So TimeScore keeps a second score that doesn’t fade at all. Instead of shrinking old events, it grows new ones: each event’s value is multiplied by , where is measured from some fixed starting time (). An event one half-life later counts twice as much, two half-lives later four times as much, and so on. The time-invariant TimeScore is the sum of those:
Here’s how it relates to the TimeScore. Starting from the TimeScore’s definition, each step rewrites it until appears:
So : the TimeScore times a factor that, at any moment, is the same for every item. That means it ranks items in the same order. And since nothing in its definition depends on the current time , it changes only when an event is added. Adding an event with value at time just adds one more term to the sum:
Storing the log
There’s one catch. doubles every half-life. With an eight-hour half-life, it passes the largest number a double can hold () in less than a year. So I store its base-2 log instead, which only grows by one per half-life:
An item with no events has , so it starts at .
Updating means taking the log of the update above. But that can’t be computed as written: it needs , the very number that overflows. Each step below rewrites it until nothing overflows:
Here is the log score the new event would have on its own, and and are the larger and smaller of the two logs.
The last line is the update TimeScore uses, and nothing in it overflows. is never positive, so is between 0 and 1, and the new is the larger of the two logs plus a correction between 0 and 1:
- If the item already has a high score and gets one more like, the correction is tiny.
- If an item’s only like came at time and a second like comes at the same moment, then and the correction is exactly : the score doubles.
- For an item’s first event, , so and simply becomes .
(This is the base-2 version of the “log-sum-exp” trick used throughout numerical computing.)
Back to the current score
To show an item’s current score, start from the end of the first derivation:
That’s all there is to it. Each item stores one number. Adding an event needs only that number, the event’s value and the event’s time. Events can even be added out of order. And sorting by the stored number, say with an index in a database, ranks items by their current TimeScore.
The code
Here is TimeScore in JavaScript and in Python. The JavaScript module is the
one the demo above uses. Both work for any half-life, in any unit of time.
In addEvent, logEvent is , and high and low are and
. Math.log1p(x) / Math.LN2 is , computed
accurately even when is tiny.
// TimeScore: a time-weighted score, stored as a single number per item.
//
// Every event (a like, a click, a share) is worth its full value when it
// happens, half as much one half-life later, a quarter after two, and so on.
// An item's score at any moment is the sum of its events' faded values.
//
// Rather than fading old events as time passes, each event is boosted by
// 2^(time / halfLife) when it is added, and the item stores the base-2 log of
// the sum. Every item's score fades at the same rate, so the stored numbers
// never change between events, and sorting by them ranks items by their
// current score. (The log keeps the number small: the sum itself would pass
// the largest double after 1024 half-lives.)
//
// Times can be in any unit (hours, seconds, days) measured from any fixed
// epoch, as long as the half-life is in the same unit.
//
// TimeScore is in the public domain: free for anyone to use. If you use it,
// a credit is appreciated: "TimeScore algorithm courtesy Mike Koss"
// (https://mckoss.com).
/** The stored score of an item with no events yet. */
export const EMPTY = -Infinity;
/**
* Add an event to an item's stored score.
*
* @param {number} logScore the item's stored score (EMPTY if it has none)
* @param {number} value the event's value, greater than 0 (1 for a like)
* @param {number} time when the event happened
* @param {number} halfLife the time it takes an event's value to halve
* @returns {number} the item's new stored score
*/
export function addEvent(logScore, value, time, halfLife) {
if (!(value > 0)) throw new RangeError(`TimeScore values must be greater than 0, not ${value}`);
const logEvent = Math.log2(value) + time / halfLife;
// log2(2^logScore + 2^logEvent), computed without leaving the log domain.
const high = Math.max(logScore, logEvent);
const low = Math.min(logScore, logEvent);
if (low === -Infinity) return high;
return high + Math.log1p(2 ** (low - high)) / Math.LN2;
}
/**
* An item's score at a given time, at or after its latest event: the sum of
* its events' values, each halved for every half-life since it happened.
*
* @param {number} logScore the item's stored score
* @param {number} time the time to score it at
* @param {number} halfLife the same half-life its events were added with
* @returns {number} the item's score at that time
*/
export function scoreAt(logScore, time, halfLife) {
return 2 ** (logScore - time / halfLife);
}
"""TimeScore: a time-weighted score, stored as a single number per item.
Every event (a like, a click, a share) is worth its full value when it
happens, half as much one half-life later, a quarter after two, and so on.
An item's score at any moment is the sum of its events' faded values.
Rather than fading old events as time passes, each event is boosted by
2 ** (time / half_life) when it is added, and the item stores the base-2 log
of the sum. Every item's score fades at the same rate, so the stored numbers
never change between events, and sorting by them ranks items by their
current score. (The log keeps the number small: the sum itself would pass
the largest float after 1024 half-lives.)
Times can be in any unit (hours, seconds, days) measured from any fixed
epoch, as long as the half-life is in the same unit.
TimeScore is in the public domain: free for anyone to use. If you use it,
a credit is appreciated: "TimeScore algorithm courtesy Mike Koss"
(https://mckoss.com).
"""
import math
EMPTY = -math.inf
"""The stored score of an item with no events yet."""
def add_event(log_score: float, value: float, time: float, half_life: float) -> float:
"""Add an event to an item's stored score, and return the new stored score.
log_score: the item's stored score (EMPTY if it has none)
value: the event's value, greater than 0 (1 for a like)
time: when the event happened
half_life: the time it takes an event's value to halve
"""
if not value > 0:
raise ValueError(f"TimeScore values must be greater than 0, not {value}")
log_event = math.log2(value) + time / half_life
# log2(2**log_score + 2**log_event), computed without leaving the log domain.
high = max(log_score, log_event)
low = min(log_score, log_event)
if low == -math.inf:
return high
return high + math.log1p(2.0 ** (low - high)) / math.log(2)
def score_at(log_score: float, time: float, half_life: float) -> float:
"""An item's score at a given time, at or after its latest event: the sum
of its events' values, each halved for every half-life since it happened.
log_score: the item's stored score
time: the time to score it at
half_life: the same half-life its events were added with
"""
return 2.0 ** (log_score - time / half_life)
Using it in JavaScript, liking a post adds an event to its stored score, and the list sorts on it:
import { EMPTY, addEvent, scoreAt } from './timescore.js';
const HALF_LIFE = 8; // hours
const hours = () => Date.now() / 3.6e6; // hours since 1970
post.timeScore ??= EMPTY;
post.timeScore = addEvent(post.timeScore, 1, hours(), HALF_LIFE);
posts.sort((a, b) => b.timeScore - a.timeScore); // hottest first
const shown = scoreAt(post.timeScore, hours(), HALF_LIFE);
Compared with Reddit
Reddit’s “hot” ranking, in the code it published as open source, works in much the same way:
order = log10(max(abs(ups - downs), 1))
seconds = post_time - 1134028003
hot = sign * order + seconds / 45000
That’s the log of a post’s votes, plus a term that grows steadily with time: just like above, in base 10 rather than 2. A post needs ten times the votes to make up for being 45,000 seconds (12.5 hours) older. That makes it a TimeScore with a half-life of hours.
The difference is which time each vote counts at. Reddit counts every vote at the time the post was submitted, however much later it’s cast. TimeScore counts each like at the time it happens. That matters in two ways:
- An old post can come back. With Reddit’s formula, a day-old post needs about 80 times the votes of a new one to rank beside it, even if every one of those votes came in the last hour. Under TimeScore, a post that’s being liked right now is hot again, whatever its age.
- Reddit’s hot score only works for posts. It needs a post time. TimeScore needs only events, so it can rank anything people act on over time: links shared again and again, tags, search results people click, users' reputations, even the IP addresses sending a server the most requests.
The patent
I filed a patent on this method in September 2009, and it was granted in March 2012 as U.S. Patent 8,140,541, “Time-weighted scoring system and method” (also on Google Patents).
I’ve placed TimeScore in the public domain: anyone is free to use it, and I won’t enforce the patent. If you do use it, I’d appreciate a credit such as “TimeScore algorithm courtesy Mike Koss.”
The patent also describes keeping more than one score per item, with different half-lives, such as a day and a week. Comparing them shows what’s trending: an item whose one-day score is high compared with its one-week score is getting more attention than usual.
As I described it in the patent, each item kept three values: its faded score, the time that score was last updated, and the log score. I realized later that the log score is all you need: the update above works from it alone, with no record of when the item last changed.