Fair rankings
Lists ranked fairly: votes by the Wilson score, star ratings by a Bayesian average or a Dirichlet score so a few votes do not win, hot lists as Reddit and Hacker News rank them, time decay, trending counts, and ties ranked together.
Version 1.0.0 · 8 functions · 1 crate
Functions
What use rankings.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use rank_wilson, rank_bayesian from rankings (Quillo 0.6).
rank_wilson(positive: int, total: int, confidence: float) → floator an errorThe Wilson score's lower bound for positive of total votes at confidence (0.95): the share of up-votes you can be sure of. Rank comments and products by it.
rank_bayesian(mean: float, count: int, prior_mean: float, prior_count: float) → floatA Bayesian average: mean of count ratings pulled toward prior_mean as if prior_count ratings of it came first (IMDb's weighted rating with the site's mean and a minimum count).
rank_stars(counts: list, confidence: float) → anyor an errorA star rating's score from its counts per star (counts[0] one-star votes ... the last the top), Evan Miller's way: the Dirichlet posterior's mean less z standard deviations (z from confidence, 0.95). Gives { score, mean, votes }.
rank_hot(ups: int, downs: int, created: int) → floatReddit's "hot": the log of the net votes plus the post's age in 12.5-hour steps, so new posts rise and old ones sink without being re-scored. created in Unix seconds.
rank_hacker_news(points: int, age_hours: float, gravity: float) → floatHacker News's ranking: (points − 1) / (age in hours + 2)^gravity (1.8).
rank_decay(score: float, age_hours: float, half_life_hours: float) → floatA score halved every half_life_hours: for "popular this week" lists that forget old activity.
rank_trending(history: list, recent: float) → anyHow much a count is trending: recent against the history of earlier periods (counts per day, say), as a z-score with the spread floored (so a quiet item's first sales do not look like a storm). Gives { z, ratio (recent / usual), usual, trending (z ≥ 2) }.
rank_items(items: list, method: str, options: any) → listor an errorA list ranked: items are maps, method one of "wilson" (fields up, down or total), "bayesian" (rating, votes; options.prior_mean defaults to the list's mean, options.prior_count 10), "stars" (counts), "hot" (up, down, created), "hacker_news" (points, age_hours) or "field" (options.field). Gives the items best first with score and rank added (ties share a rank, "1, 2, 2, 4").
Get it
$ quillo install rankings # rankings.qwer into this folder$ quillo install rankings --example # and its example, rankings.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/rankings.qwer$ curl -O https://quillolang.org/plugins/files/rankings.qweThen, in your program, all of it or only what you call:
use rankings.qweruse rank_wilson, rank_bayesian from rankings // Quillo 0.6: only theseIt uses this crate, which cargo fetches the first time the plugin is built:
statrs = "0.17"Example
// A recipe site's lists ranked fairly: comments by the Wilson score of their votes, recipes by a// Bayesian average and by their star counts (few votes do not win), a "hot" front page, and// which search terms are trending today.//// quillo run rankings.qwe then open http://localhost:8080/recipes and http://localhost:8080/scoresuse "rankings.qwer"let RECIPES = [ { name: "Lemon tart", rating: 4.9, votes: 7, counts: [0, 0, 0, 1, 6] }, { name: "Ragù", rating: 4.6, votes: 2140, counts: [21, 43, 150, 470, 1456] }, { name: "Brownies", rating: 4.7, votes: 310, counts: [3, 5, 20, 40, 242] }, { name: "Kale smoothie", rating: 3.1, votes: 95, counts: [20, 15, 20, 20, 20] },]route GET /recipes = { bayesian: rank_items(RECIPES, "bayesian", { prior_count: 50.0 })? | map((x) => `${x.rank}. ${x.name} (${x.score})`), stars: rank_items(RECIPES, "stars", {})? | map((x) => `${x.rank}. ${x.name} (${x.score})`),}route GET /scores = { wilson: [rank_wilson(9, 10, 0.95)?, rank_wilson(900, 1000, 0.95)?], comments: rank_items([{ id: 1, up: 5, down: 0 }, { id: 2, up: 80, down: 12 }, { id: 3, up: 0, down: 0 }, { id: 4, up: 40, down: 6 }], "wilson", {})? | map((c) => [c.id, c.rank]), imdb: rank_bayesian(9.2, 1200, 6.9, 25000.0), stars: rank_stars([0, 0, 0, 0, 3], 0.95)?, hot: rank_hot(250, 20, 1761750000), hn: rank_hacker_news(120, 5.0, 1.8), decay: rank_decay(100.0, 48.0, 24.0), trending: rank_trending([12.0, 9.0, 15.0, 11.0, 10.0, 13.0, 12.0], 41.0), ties: rank_items([{ n: "a", s: 3 }, { n: "b", s: 5 }, { n: "c", s: 5 }, { n: "d", s: 1 }], "field", { field: "s" })? | map((x) => `${x.n}:${x.rank}`),}$ quillo run rankings.qwe$ curl 'localhost:8080/recipes'$ curl 'localhost:8080/scores'Source
//! Lists ranked fairly: up/down votes by the Wilson score's lower bound, star ratings by a Bayesian average or Evan Miller's Dirichlet score (so 4.8 from 5 votes does not beat 4.6 from 2,000), "hot" lists as Reddit and Hacker News rank them, time decay, trending by how far today is above normal, and whole lists sorted with competition ranks for ties.//! version: 1.0.0//!//! [dependencies]//! statrs = "0.17"use serde_json::{json, Value};use statrs::distribution::{ContinuousCDF, Normal};fn r(x: f64) -> f64 { (x * 1e6).round() / 1e6}fn z_of(confidence: f64) -> f64 { let c = if confidence > 0.0 && confidence < 1.0 { confidence } else { 0.95 }; Normal::new(0.0, 1.0).unwrap().inverse_cdf(1.0 - (1.0 - c) / 2.0)}fn wilson(pos: f64, n: f64, z: f64) -> f64 { if n <= 0.0 { return 0.0; } let p = pos / n; (p + z * z / (2.0 * n) - z * ((p * (1.0 - p) + z * z / (4.0 * n)) / n).sqrt()) / (1.0 + z * z / n)}/// The Wilson score's lower bound for `positive` of `total` votes at `confidence` (0.95): the share of up-votes you can be sure of. Rank comments and products by it.pub fn rank_wilson(positive: i64, total: i64, confidence: f64) -> Result<f64, String> { if positive < 0 || total < positive { return Err("0 ≤ positive ≤ total".into()); } Ok(r(wilson(positive as f64, total as f64, z_of(confidence))))}/// A Bayesian average: `mean` of `count` ratings pulled toward `prior_mean` as if `prior_count` ratings of it came first (IMDb's weighted rating with the site's mean and a minimum count).pub fn rank_bayesian(mean: f64, count: i64, prior_mean: f64, prior_count: f64) -> f64 { let (n, m) = (count.max(0) as f64, prior_count.max(0.0)); if n + m == 0.0 { return r(prior_mean); } r((n * mean + m * prior_mean) / (n + m))}/// A star rating's score from its counts per star (`counts[0]` one-star votes ... the last the top), Evan Miller's way: the Dirichlet posterior's mean less `z` standard deviations (z from `confidence`, 0.95)./// Gives `{ score, mean, votes }`.pub fn rank_stars(counts: Vec<i64>, confidence: f64) -> Result<serde_json::Value, String> { if counts.len() < 2 || counts.iter().any(|c| *c < 0) { return Err("counts for each star, from the lowest, none negative".into()); } let k = counts.len() as f64; let n: f64 = counts.iter().sum::<i64>() as f64; let z = z_of(confidence); let (mut m1, mut m2) = (0.0, 0.0); for (i, c) in counts.iter().enumerate() { let s = (i + 1) as f64; m1 += s * (*c as f64 + 1.0) / (n + k); m2 += s * s * (*c as f64 + 1.0) / (n + k); } let score = m1 - z * ((m2 - m1 * m1) / (n + k + 1.0)).sqrt(); let plain = if n > 0.0 { counts.iter().enumerate().map(|(i, c)| (i + 1) as f64 * *c as f64).sum::<f64>() / n } else { 0.0 }; Ok(json!({ "score": r(score), "mean": r(plain), "votes": n as i64 }))}/// Reddit's "hot": the log of the net votes plus the post's age in 12.5-hour steps, so new posts rise and old ones sink without being re-scored. `created` in Unix seconds.pub fn rank_hot(ups: i64, downs: i64, created: i64) -> f64 { let s = (ups - downs) as f64; let order = s.abs().max(1.0).log10(); let sign = s.signum(); r(sign * order + (created as f64 - 1134028003.0) / 45000.0)}/// Hacker News's ranking: (points − 1) / (age in hours + 2)^gravity (1.8).pub fn rank_hacker_news(points: i64, age_hours: f64, gravity: f64) -> f64 { r(((points - 1).max(0) as f64) / (age_hours.max(0.0) + 2.0).powf(if gravity > 0.0 { gravity } else { 1.8 }))}/// A score halved every `half_life_hours`: for "popular this week" lists that forget old activity.pub fn rank_decay(score: f64, age_hours: f64, half_life_hours: f64) -> f64 { r(score * 0.5f64.powf(age_hours.max(0.0) / half_life_hours.max(1e-9)))}/// How much a count is trending: `recent` against the `history` of earlier periods (counts per day, say), as a z-score with the spread floored (so a quiet item's first sales do not look like a storm)./// Gives `{ z, ratio (recent / usual), usual, trending (z ≥ 2) }`.pub fn rank_trending(history: Vec<f64>, recent: f64) -> serde_json::Value { let n = history.len().max(1) as f64; let mean = history.iter().sum::<f64>() / n; let var = history.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n; // Poisson-like floor: a count's spread is at least its square root, and at least 1. let sd = var.sqrt().max(mean.sqrt()).max(1.0); let z = (recent - mean) / sd; json!({ "z": r(z), "ratio": if mean > 0.0 { json!(r(recent / mean)) } else { Value::Null }, "usual": r(mean), "trending": z >= 2.0 })}/// A list ranked: `items` are maps, `method` one of "wilson" (fields `up`, `down` or `total`), "bayesian" (`rating`, `votes`; `options.prior_mean` defaults to the list's mean, `options.prior_count` 10), "stars" (`counts`), "hot" (`up`, `down`, `created`), "hacker_news" (`points`, `age_hours`) or "field" (`options.field`)./// Gives the items best first with `score` and `rank` added (ties share a rank, "1, 2, 2, 4").pub fn rank_items(items: Vec<serde_json::Value>, method: &str, options: serde_json::Value) -> Result<Vec<serde_json::Value>, String> { let f = |it: &Value, k: &str| it[k].as_f64().unwrap_or(0.0); let conf = options["confidence"].as_f64().unwrap_or(0.95); let prior_mean = options["prior_mean"].as_f64().unwrap_or_else(|| { let ws: f64 = items.iter().map(|i| f(i, "votes")).sum(); if ws > 0.0 { items.iter().map(|i| f(i, "rating") * f(i, "votes")).sum::<f64>() / ws } else { 0.0 } }); let prior_count = options["prior_count"].as_f64().unwrap_or(10.0); let mut scored: Vec<(f64, Value)> = items .into_iter() .map(|it| { let s = match method { "wilson" => { let up = f(&it, "up"); let total = if it["total"].is_number() { f(&it, "total") } else { up + f(&it, "down") }; wilson(up, total, z_of(conf)) } "bayesian" => rank_bayesian(f(&it, "rating"), f(&it, "votes") as i64, prior_mean, prior_count), "stars" => { let c: Vec<i64> = it["counts"].as_array().map(|a| a.iter().map(|x| x.as_i64().unwrap_or(0)).collect()).unwrap_or_default(); rank_stars(c, conf)?["score"].as_f64().unwrap_or(0.0) } "hot" => rank_hot(f(&it, "up") as i64, f(&it, "down") as i64, f(&it, "created") as i64), "hacker_news" => rank_hacker_news(f(&it, "points") as i64, f(&it, "age_hours"), options["gravity"].as_f64().unwrap_or(1.8)), "field" => f(&it, options["field"].as_str().ok_or("give options.field")?), other => return Err(format!("method {:?}: wilson, bayesian, stars, hot, hacker_news or field", other)), }; Ok((r(s), it)) }) .collect::<Result<_, String>>()?; scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); let mut out = vec![]; let mut rank = 0; let mut prev = f64::NAN; for (i, (s, mut it)) in scored.into_iter().enumerate() { if s != prev { rank = i + 1; prev = s; } if let Value::Object(m) = &mut it { m.insert("score".into(), json!(s)); m.insert("rank".into(), json!(rank)); } out.push(it); } Ok(out)}Related
ngrams.qwer · 1.0.0N-grams and shingles: word and character n-grams, the phrases a text uses most, how alike two texts are by shared shingles despite typos, and near-duplicates found among thousands of texts with MinHash.6 views · 10 downloadsAnomaly detection anomalies.qwer · 1.0.0Outliers and odd moments found in numbers: z-scores, the robust MAD score, IQR fences and Grubbs' test; and in series over time, spikes against a rolling window or the same hour of past days, EWMA control limits and level shifts by CUSUM.8 views · 8 downloadsClustering clustering.qwer · 1.0.0Data put into groups: k-means with k chosen by the elbow and silhouette, DBSCAN for groups of any shape and their outliers (in metres for latitude and longitude), and hierarchical clustering, on rows of numbers or maps.7 views · 7 downloadsForecasting forecast.qwer · 1.0.0Forecasts from a series of numbers: moving averages, exponential smoothing, Holt's trend and Holt-Winters seasons with their weights fitted, the best model chosen on held-out data, prediction intervals, and accuracy measures.8 views · 10 downloadsMatrices matrix.qwer · 1.0.0Matrices as lists of rows: linear systems solved, inverses, determinants, products and transposes, and least-squares fits such as a trend line.7 views · 9 downloadsPlayer ratings ratings.qwer · 1.0.0Player and team ratings from results: Elo with each side's chance, Glicko-2 with uncertainty and volatility, TrueSkill for two teams with draws, and a season's games run in order into a table.8 views · 10 downloadsThread 0 posts
On Fair rankings: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.