ガイド 第27

スコアリング & リーダーボード

IMDb 加重・プレミアム・キャリブレーション

@event/feedback

評価対象 (subject) のランキングは「少票で 90 点台に乗せない」ことが唯一かつ最大の設計目標である。 1 票の 😍 が満票 30 票の熱狂と同列に並ぶと、リーダーボードは信用を失う。@event/feedbackranking.ts は IMDb 型の加重レーティング (真の平均を票数で会場ベースラインへ縮約) を運用目標に校正した 純関数群で、UI も I/O も持たない。中核は rankSubjects (全体) / subjectScore (1 対象) / rankingContext (基準値の導出) の 3 つ。ivs-cms の登壇者評価から speaker→subject / session→unit に一般化した版である。

RatingScale の 2 系統 — values と scores は別物

RatingScale は 1 つの評価キー (例 great) に 2 系統の数値を持たせる。ここを混同すると校正が崩れる。

系統用途既定 (DEFAULT_FACE_SCALE)
values0–100 格子上の「票の値」。ランキング計算 (quality / 会場平均) の唯一の入力😍 great=100 / 😊 good=50 (中立) / 😐 ok=0
scores表示用スコア。平均表示 (rawAvg / globalMean) にだけ使うgreat=3 / good=2 / ok=1

values は「😊=50 を中立、😐=0 をマイナス評価」とする 0–100 スケールで、score 計算は全部こちらを読む。 scores は会場向けに「平均 2.7 点」と出すための表示専用で、ランキング順位には一切効かない。スケールを 差し替えれば段階数も自由 (scale.keys の best→worst 順、😍 = values 最大キー、😐 = 最小キー)。

基本式 — 縮約と貸し付けの二項

subjectScore が返す最終スコアは 3 項の合成である (NEUTRAL = 50 中立を起点に、質と会場基準を票数で乗せる):

score = 50 + (quality − 50) × w(v) + (baseline − 50) × b(v)

第 2 項が「その対象の質を票数で信じる度合い」、第 3 項が「甘い会場ぶんの下駄を票数に応じて貸す」項。 どちらも票数 v が小さいほど 0 に潰れ、score は中立 50 へ縮約される (= IMDb の v/(v+m) 型)。

実定数 (2026-07 確定版)

定数役割
NEUTRAL50中立値。全員 😊 なら quality はここに一致
GREAT_EXP1.5😍率プレミアムの指数。quality の 😍 項を p😍^1.5 にし、😊 で薄まった 😍率ほど超線形に下がる
OK_PENALTY30😐 の追加減点。quality から √p😐 × 30 を引く (1 件でも明確に効き、以降は逓減の凹型)
VOTES_M9質の信頼係数 w(v)=v/(v+9) の半減点。1 票で w=0.1 → 中身で ±5 だけ動く
BASE_RATE0.35会場基準の貸し付け率の上限
BASE_M5貸し付けの立ち上がり。b(v)=0.35×(v−1)/(v+5)1 票では 0
BASELINE_NEUTRAL_MIX0.35会場平均を中立側へ引き戻す率。baseline = 50 + (会場平均−50)×(1−0.35)

quality — 加重平均 + 😍プレミアム − 😐ペナルティ

quality は票の values 加重平均に、😍率の凸プレミアムと 😐率の凹ペナルティを重ねて 0–100 に clamp する:

quality = mean + 50 × (p😍^GREAT_EXP − p😍) − OK_PENALTY × √p😐
        = mean + 50 × (p😍^1.5 − p😍) − 30 × √p😐

meanvalues の加重平均 (100·p😍 + 50·p😊 + 0·p😐)。純ケース (😍のみ / 😊のみ / 😐のみ) では プレミアム項が 0 になるので校正アンカーは不変。混在時だけ「😊 で薄まった 😍率」が p^1.5 で超線形に沈み、 「😍率95%の21票」が「😍率85%の33票」に票数だけでは抜かれない。

baseline — 会場の甘辛を保守化した基準値

rankingContext(tallies, scale) が母集団全体の values 加重平均 (= 会場平均 = この回の 😍率の甘辛) を出し、 中立 50 へ BASELINE_NEUTRAL_MIX ぶん引き戻して { baseline } を返す。会場平均 83 の甘い回でも基準値は 50 + (83−50)×0.65 ≈ 71.5 に抑えられ、貸し付け項が過剰に効かない。rankSubjects は全対象の tally から この ctx を 1 度だけ導出し、各 subjectScore に渡す (全体 / unit 内で母集団を切り替えれば相対評価になる)。

校正アンカー (会場平均 ~83 のとき)

票構成1票2票3票6票10票20票30票50票
😍 のみ5560647481909598
1票の内訳😍→55 / 😊→50 / 😐→45

1 票は中身で ±5 しか動かず (w=0.1 かつ貸し付け 0)、90 点台は大票数の熱狂だけが到達する。

使い方

import { rankSubjects, subjectScore, rankingContext, VOTES_M } from "@event/feedback";
import type { FeedbackResponse, RankingResult } from "@event/feedback";

// 全 unit 横断でランク付け (minVotes 未満は除外。既定 1)。
const result: RankingResult = rankSubjects(responses, { minVotes: 3 });
result.subjects.forEach((s) => {
  // rank / score(0–100) / quality / confidence(0–1) / votes / counts / units
  console.log(s.rank, s.subjectId, s.score, `(${s.votes}票)`);
});
result.baselineQuality; // 実データ由来の基準値 / result.meanVotes / globalMean

// 1 対象だけを試算 (プレビュー): ctx を渡さなければ中立基準 (baseline=50)。
const ctx = rankingContext([{ great: 20, good: 3, ok: 1 }]);
subjectScore({ great: 20, good: 3, ok: 1 }, ctx).score; // → 0–100

rankSubjects の戻り RankingResultsubjects (score 降順・rank 採番済) に加え globalMean (scores 平均) / totalVotes / totalResponses / baselineQuality / meanVotes を持つ。同点は 票数 → 😍 (top key) 数の順で割る。各 SubjectRankingcountsscale.keys 全キーを 0 埋めで 返すので、UI の分布バーはそのまま描ける。全て pure なので server component でも client でも同じ結果になる。 配線手順は recipe add-surveys 参照。