はじめに
日本の YouTube でいま伸びている動画を自動で観測し、「🌿成長」「🌸開花」「🍃ひと息」のような状態で見せるサイト、GrowSignal Video を作っています(前回の記事でコンセプトを紹介しました)。
この記事は、動画の「熱量」が毎日上がったり下がったりする1日の波との格闘記です。
一度は「賢い補正」で解決したつもりでしたが、実データでかえって波を増幅していました。最後に落ち着いたのは、拍子抜けするほど素朴なやり方です。
状態の決め方(おさらい)
1本ごとに「熱量」(1時間あたりの再生の伸びに、高評価の伸びを再生換算して足したもの)を出し、2つの軸で状態を決めます。
- 水準: その動画自身のピークと比べて、今どれくらいか(高い/中くらい/低い)
- 動き: 直近12時間と、その前の12時間の比(伸びている/横ばい/落ちている)
| 伸びている | 横ばい | 落ちている | |
|---|---|---|---|
| 高い | 🌿成長 | 🌸開花 | 🌸開花 |
| 中くらい | 🌿成長 | 🌳安定 | 🍃ひと息 |
| 低い | 🌳安定 | 🌳安定 または 🍃ひと息 ※ | 🌳安定 または 🍃ひと息 ※ |
※ 見られ続けていれば 🌳安定、そうでなければ 🍃ひと息。
観測は3時間おきで、YouTube Data API から再生数と高評価数を記録しています。
問題:状態が毎日同じ時刻に入れ替わる
運用してみると、1本あたり3日で2.5〜3回も状態が変わっていました(Movie 2.53回、Short 3.24回)。グラフを眺めていて気づいたのは、上がり下がりがぴったり1日周期だったことです。
「直近12時間とその前の12時間を比べる」ということは、比べているのはほぼ昼と夜です。昼には「前より伸びている」、深夜から朝には「前より落ちている」と判定され、話題とは関係なく状態が入れ替わっていました。
1回目の直し方:全動画に共通の「時間帯の係数」で割る
季節調整と同じ考え方で、1日の波を取り除いてから判定することにしました。
- 直近7日の観測から、動画ごとに「日本時間の各時間帯にどれだけ伸びたか」の比率を出す
- 時間帯ごとに全動画の中央値を取り、平均が1になる24個の係数にする
- 判定の前に、伸びをその時間帯の係数で割る
動画ごとではなく全動画に共通の係数にしたのは、急上昇に入ったばかりの動画には自分の波を知るだけの履歴がないからです。できあがった係数は、たとえばこうなりました(Movie、日本時間)。
| 0〜2時 | 3〜5時 | 6〜8時 | 9〜11時 | 12〜14時 | 15〜17時 | 18〜20時 | 21〜23時 |
|---|---|---|---|---|---|---|---|
| 1.27 | 0.80 | 0.49 | 0.86 | 1.04 | 1.02 | 1.14 | 1.39 |
これで解決……と思っていました。
落とし穴1:ならした値に、今の係数を掛けてはいけない
最初に見つけたのは、グラフを描くときのバグです。
保存している熱量は、観測ごとの速度を**時定数6時間の指数移動平均(EMA)**でならした値です。ならした値は、実際の動きより数時間遅れます。そこに「今の時間帯の係数」をそのまま掛けたり割ったりすると、位相がずれて偽の山ができます。
人気が一定(100回/時)で1日の波だけがある動画でシミュレーションすると、次のようになりました。
| 値の範囲 | |
|---|---|
| ならした値(実際) | 60〜140 |
| 今の係数で割る | 70〜257(元の波より大きい偽の山) |
| EMA を逆にたどって速度に戻し、区間の係数で割ってからならし直す | 100〜100 |
EMA は逆にたどれます。g_k = g_{k-1} + α(G_k − g_{k-1}) なので、G_k = g_{k-1} + (g_k − g_{k-1}) / α で観測ごとの速度に戻せます。これで直りました。……が、本当の問題はその先にありました。
落とし穴2:波の形は、動画ごとに違う
「1日の波を除いた値」のグラフを眺めていると、まだ1日周期の山が残っている、むしろ大きくなっている動画がありました。
その動画の実際の記録から、時間帯ごとの伸び(1日の平均を1とした比率)を出して、共通の係数と並べてみます。
| 日本時間 | 0〜2時 | 3〜5時 | 6〜8時 | 9〜11時 | 12〜14時 | 15〜17時 | 18〜20時 | 21〜23時 |
|---|---|---|---|---|---|---|---|---|
| この動画 | 0.41 | 0.05 | 0.19 | 1.27 | 1.60 | 1.42 | 1.63 | 1.43 |
| 共通の係数 | 1.27 | 0.80 | 0.49 | 0.86 | 1.04 | 1.02 | 1.14 | 1.39 |
形がまったく違います。この動画は深夜から朝8時までほとんど伸びないのに、共通の係数では0〜2時がむしろ高いのです。この動画を共通の係数で割ると、朝9〜11時の伸びは1.27から1.48に持ち上がってしまいます。
3〜5時の伸びが昼の約30分の1というのも、「人が寝ている」だけでは説明しにくい極端さです。YouTube の公開の再生数は数時間まとめて反映されることもあるようで、それも含めて、波の形は動画ごとにかなり違うと考えるようになりました。
中央値の係数は「平均的な動画」には効きます。ですが、形の違う動画では波を消せず、崩してしまいます。
最終的な直し方:1日ぶんを丸ごと平均する
係数で割るのをやめました。代わりに、判定に使う熱量を直近24時間の平均にします。
// 判定に使う熱量 v = 直近24時間(今の観測を含む)の熱量 m の平均
const dayPoints = kept.filter(([t]) => t > nowMs - 24 * HOUR_MS);
const v = (dayPoints.reduce((a, [, mm]) => a + mm, 0) + m) / (dayPoints.length + 1);
// ピークも v で持つ
row.mPeak = Math.max(row.mPeak, v);
// 水準 = 今の v / v のピーク
const L = v / row.mPeak;
// 動き = 今の v と、12時間前の v の対数差
const D = Math.log(v + eps) - Math.log(v12hAgo + eps);
1日ぶんを丸ごと平均すれば、波がどんな形でも消えます。係数を推定しないので、推定が外れて増幅することもありません。統計の言葉でいえば、周期と同じ長さの移動平均で季節成分を消す、いちばん古典的なやり方です。
代わりに、動きへの反応は少し遅くなります。ただ、このサイトで見たいのは「数時間の揺れ」ではなく「その動画がいまどの時期にいるか」なので、この交換は割に合います。
効果
問題の動画の実際の記録(9/19〜9/27)を、3つのやり方で判定にかけました。
| やり方 | 状態が変わった回数 |
|---|---|
| 補正なし | 15回(🌿と🍃を毎日のように行き来) |
| 共通の係数で割る | 16回(かえって増えた) |
| 24時間の平均 | 9回 |
24時間の平均では、1日目に🌸開花、その後はゆっくり落ちていく途中で🍃と🌳を行き来し、再び伸びた日に🌿成長、という「その動画の一生」に近い流れになりました。
詳細ページでは「実際の値」と「24時間の平均」を切り替えられるようにしています。波そのものは事実なので、消さずに見られるようにしました。
副作用:最初の24時間は「集計中」にする
24時間の平均には、当然ながら24時間ぶんの記録が要ります。観察を始めたばかりの動画は、数時間ぶんの平均しかありません。
これが一覧の並び順で問題になりました。一覧は「熱量が12時間前と比べて何%変わったか」で並べています。観察1日目の動画は、平均に含まれる時間が少ないため値が大きく跳ね、+1686% のような数字で一覧の先頭を占めてしまいました。
そこで、観察を始めて24時間までは状態を「🌱タネ」とし、カードには変化率の代わりに「⚡ 集計中」と出すことにしました。24時間の平均がそろってから判定と並べ替えに加えます。「平均が1日ぶんそろうまでは判断しない」という、これまた素朴なルールです。
カードの数字も「熱量の変化」に
状態の判定を24時間の平均に揃えたので、カードの数字もそれに合わせました。
以前は「1日の伸び率(総再生数に対する割合)」を出していましたが、これだと熱量が落ちている動画でも「+50%」のように見えてしまいます。総再生数は減らないので、伸び率は常にプラスだからです。
今は、判定と同じ 24時間の平均熱量が、12時間前と比べて何%変わったか を「↗ +12%」「→ ±0」「↘ −18%」のように出しています。数字の向きと状態の向きが、いつも一致するようになりました。
おまけ:一覧ページを4秒 → 0.24秒に
一覧ページは約4,000本の動画の観測を集計するので、組み立てに約4秒かかっていました。
そこで、20分おきの cron で一覧ページを組み立てて D1 に保存し、アクセスには保存済みの HTML を返すようにしました。データ自体が20分〜3時間おきにしか変わらないので、見え方は変わりません。トップページの応答は 3.9秒 → 0.24秒(5回の中央値)になりました。
おわりに
「全動画の中央値で割る」は、いかにも賢そうな方法でした。実際に効かなかったのは、個々の動画のデータを1本ずつ眺めるまで分かりませんでした。
賢い補正より、周期ぶんを丸ごと平均する素朴な方法のほうが強い場面があります。作って毎日眺めていないと気づけない発見が、一番の収穫でした。
GrowSignal Video: https://video.growsignal.io