はじめに
嬉しい事に、Qiitaで二回ほどトレンド入りを経験したことがあります。
「はじめて個人で論文投稿をしてみた話」と「Pandasはもう古い?2026年のPythonデータ分析ライブラリを整理してみた」という二つの記事ですが、トレンド入りすると、結構関係ない記事に足を運んでくれる人が出てきます。
二回目にトレンド入りした記事では、記事が増えたこともあり、別のページのまとめを紹介をしたのですが、一回目より波及効果が大きいと感じていました。
ここで最近勉強しだした(記事「因果推論の勉強を始めたので、相関と回帰係数の意味の違いを出してみた」)、因果推論の手法を使って、一回目のトレンド入りと二回目のトレンド入りで波及効果がどの程度変わったのか調査したいと思います。
なお、因果推論に関しては、ロードマップの記事の本を買ってまだ読めていない状態なので、まだまだ勉強中です。
※因みに、Qiitは平日の方が、アクセスが多くトレンド入りを狙うなら平日投稿の方が、お勧め出来ると思います。
Causal Impactについて
いろいろ説明はあると思いますが、自分のルーツを含めた解釈を行うと、時系列データにおいて、あるインパクトがあった時、それ以前のデータからカルマンフィルタを使用して、インパクトがなかった場合のデータを予測し、その効果を見る手法だと考えています。
なお、カルマンフィルタの予測では、「トレンド(長期変動)」「周期性(曜日など)」「共変量(関係の薄い特徴量)」を用いる。
単純な前後比較では、サイト全体の成長や曜日による変動(土日にアクセスが落ちる等)を排除できません。Causal Impactは、バズと無関係な記事(共変量)の動きを「鏡」にすることで、それらの外部要因をキャンセルし、純粋な施策効果だけを抽出できるのが強みです。
詳しいお話は、調査してください。
データ取得について
データ取得に関する注意点
ga4にてQiitaの記事のアクセス数を取得しています。ただ、「探索」のレポートは、デフォルトのデータの保存期間の設定が二か月となっており、期間が不足していました。なので「探索」から詳しい情報を取得する事はあきらめました。ちゃんと設定はしておきましょう。14か月まで設定できるようです。
どうデータを取得したか
「レポート->ウェブ/アプリのトラフィックの分析->ページとスクリーン」で出力されるデータから、「レポートの共有」ボタンを押し「ファイルをダウンロード->csv形式でダウンロード」でダウンロードしています。
ここで、一つ注意点があって、このままダウンロードしてしまうと、指定期間の総数のデータを取得してしまいます。
グラフの画面下にデータの表があるのですが、データのインデックスの題名が「ページパスとスクリーンクラス」となっています。その横に「+」ボタンがあるので、そのボタンを押して、「時刻->日付」を選ぶと日ごとのデータが抽出できるようになります。
※データ解析の記事となるのでスクショなどは貼っていません。
記事のハッシュ値と記事の題名の関係は、記事の題名、ハッシュ値、投稿日付をコピペと目視で確認して、自分でせっせと作りました。
まだ、28記事くらいなのでできる作業ですね。
データ解析
基礎解析
とりあえず、時系列の表示数の把握だけ。
点線が総表示回数となっています。
トレンド入りすると記事が極端に伸びることがわかります。
まだ、今回の記事の流入が落ち着ききっていない事もわかりますが、今日中に解析したかったので解析してしまっています。
また、二回山ができている事がわかりますが、おそらく一週間おくれのe-mailのトレンド発信による山があるものと考えられます。
単なる統計的な比較
1回目のトレンド入り
1月2日の記事がトレンド入りしたのですが、この記事より二週間前に投稿した記事の平均が、トレンド入りした記事の前後でどう変わるかを見ています。
二週間おいているのは、そのくらいの期間で最初の流入が落ち着くからです。
対象記事の、1月2日の前後二週間の平均の差をトレンド入りした記事の二週間の平均表示数で割ったものを、波及率としました。
- 結果
バズ前の記事の一日平均表示:4.666666666666667
バズ後の記事の一日平均表示:11.571428571428571
バズ記事の一日平均表示:484.42857142857144
平均波及効果:1.4253%
一記事あたりの平均波及効果:0.2376%
1.4%くらいが他の記事も表示していただけるようです。
2回目のトレンド入り
1回目と同様の事を、3月8日にトレンド入りした記事で行いました。
- 結果
バズ前の記事の一日平均表示:20.866666666666667
バズ後の記事の一日平均表示:84.16666666666667
バズ記事の一日平均表示:1504.5833333333333
平均波及効果:4.2071%
一記事あたりの平均波及効果:0.2003%
平均波及効果が約2.8%向上しました。
1記事当たりの平均波及効果0.03ポイントほど下がりましたが、ほぼ横ばいとなっています。
Causal Impactを使った波及率の計算
1回目にトレンド入りした記事
上記の解析と同様に、1月2日より二週間前に投稿していた記事のそう表示数を予測する変数とします。
トレンドと関係ない変数ですが、記事の内容が独立している一つの記事に絞りました。
本来ならば、複数あり安定させた方がいいのですが、関係なさそうな記事が一つしかなかったので1記事としました。
Posterior Inference {Causal Impact}
Average Cumulative
Actual 11.57 162.0
Prediction (s.d.) 4.71 (0.94) 65.96 (13.11)
95% CI [2.89, 6.56] [40.51, 91.89]
Absolute effect (s.d.) 6.86 (0.94) 96.04 (13.11)
95% CI [5.01, 8.68] [70.11, 121.49]
Relative effect (s.d.) 145.6% (19.87%) 145.6% (19.87%)
95% CI [106.29%, 184.18%] [106.29%, 184.18%]
Posterior tail-area probability p: 0.0
Posterior prob. of a causal effect: 100.0%
- 波及効果結果
一回目 予測: 4.7, 実測: 11.6, バズ平均: 484.4, 波及率: 1.4209%, 一記事当たり: 0.2368%
単純な統計値での結果とほぼ同等の結果がでます。
2回目にトレンド入りした記事
1回目の記事と同様に、トレンド入りした記事の日付を3月8日に変更して計算を行いました。
Posterior Inference {Causal Impact}
Average Cumulative
Actual 84.17 1010.0
Prediction (s.d.) 20.99 (2.84) 251.87 (34.07)
95% CI [15.72, 26.85] [188.63, 322.19]
Absolute effect (s.d.) 63.18 (2.84) 758.13 (34.07)
95% CI [57.32, 68.45] [687.81, 821.37]
Relative effect (s.d.) 301.0% (13.53%) 301.0% (13.53%)
95% CI [273.08%, 326.1%] [273.08%, 326.1%]
Posterior tail-area probability p: 0.0
Posterior prob. of a causal effect: 100.0%
- 波及効果結果
二回目 予測: 20.9, 実測: 84.2, バズ平均: 1504.6, 波及率: 4.2032%, 一記事当たり: 0.2002%
単純な統計値での結果とほぼ同等の結果がでます。
考察
記事のリンクを適切に張った効果で、1記事当たりの波及効率が下がらず、全体の記事を見てもらえることにより、全体での波及効果が2.5%程向上する事が示された。
また、Causal Impactを用いることにより、偶然ではなく確実に記事の波及効果が、ある事が示された。
これにより、まとめ記事のリンクをした効果が良く出ている事が確認されたと考えている。
また、データの周期性を考慮した解析区間を設ければ、単純な統計比較でもよい結果が出る事も示されました。確認は、Causal Imapctでしているのですが、データの傾向がしっかり把握できていれば、単純な統計比較も悪くはないという事も示せたかと思います。
まとめ
Causal Impactを使ったトレンド入りした記事の波及効果の検証を行いました。
他の記事のまとめ記事のリンクを貼る事により、1記事あたりの回遊率をあまり下げずに、全体の回遊率を上げられることがわかりました。
Qiitaの機能で3記事まで固定できますが、それ用にまとめ記事などを作成しておくと、トレンド入りした時に、他の記事まで見てくれる可能性が高まると思います。
記事の最後に、少し説明を加えてリンクを貼った所、回遊率が上がったので、
Qiitaの機能のピン止めしたところに一行くらい説明入れられるといいなぁとも少し思いました。
※ちょっとグラフ等による可視化をサボり気味なのはお許しください。
プログラム等
適当な解析なのでちょっと雑ですがプログラムもおいておきます。
- 入れたライブラリ
uv add pandas tfcausalimpact tensorflow tf-keras
- プログラム
- read_data.py
import pandas as pd
# import re
def extract_hash(path):
# クエリパラメータ (?) 以降を無視
clean_path = str(path).split('?')[0]
# パスを / で区切って最後の要素を取得
elements = clean_path.rstrip('/').split('/')
if elements:
return elements[-1]
return None
# 1. データの読み込み
# ga4_data.csv: GA4からエクスポートしたRawデータ
# master_data.csv: ハッシュ値、ページ名、投稿日付などが入ったマスタ
def read_data(column_name):
# データ読み込み(9行目にカラム名が入っている)(一年ごとにデータを取得)
df_data1 = pd.read_csv('./data/2025_ページとスクリーン_ページパスとスクリーン_クラス.csv',header=9)
df_data2 = pd.read_csv('./data/20260319_ページとスクリーン_ページパスとスクリーン_クラス.csv',header=9)
df_ga4 = pd.concat([df_data1,df_data2])
df_master = pd.read_csv('./data/ハッシュ値とページ名.csv')
# 2. 必要な列の抽出とマージ
# GA4の「ページパスとスクリーン クラス」と マスタの「ページ名(パス)」を結合
# ※GA4側が "/entry/hash" のような形式なら、マスタ側と形式を合わせる必要があります
df_ga4['hash_extracted'] = df_ga4['ページパスとスクリーン クラス'].apply(extract_hash)
df_merged = pd.merge(
df_ga4,
df_master,
left_on='hash_extracted',
right_on='ハッシュ値',
how='inner'
)
# 3. 日付型への変換(GA4の形式に合わせて調整してください。例: 20240101 または 2024-01-01)
df_merged['日付'] = pd.to_datetime(df_merged['日付'].astype(str))
# 4. ピボット処理:行を「日付」、列を「各記事(ハッシュ値)」にする
# これで、Causal Impactに入れやすい「横並び」の形になります
df_pivot = df_merged.pivot_table(
index='日付',
columns='ハッシュ値',
values=column_name,
aggfunc='sum'
)
# 5. 【重要】アクセス0の日を補完する
# カレンダー通りの日付インデックスを作成し、値がない場所を0で埋める
all_days = pd.date_range(start=df_pivot.index.min(), end=df_pivot.index.max(), freq='D')
df_final = df_pivot.reindex(all_days).fillna(0)
# 6. 確認
print(df_final.head())
# CSVとして保存
# df_final.to_csv('processed_pv_data.csv')
return df_final
- causal_impact.py
import pandas as pd
import datetime as dt
import read_data
import numpy as np
from causalimpact import CausalImpact
import matplotlib.pyplot as plt
# 分析設定
FIRST_BUZZ_TITLE = 'はじめて個人で論文投稿してみた話'
SECOND_BUZZ_TITLE = 'Pandasはもう古い?2026年のPythonデータ解析ライブラリを整理してみた'
MIRROR_ARTICLE_TITLE = '過学習しそうな特徴量をnanにしたら精度が上がった話:選択的欠損化'
def calculate_ripple_effect(ci_result, actual_data, buzz_data, period, num_of_article,label=''):
"""
CausalImpactの結果から波及率を計算して表示する
"""
post_start, post_end = period
mean_pred = ci_result.loc[post_start:post_end, 'complete_preds_means'].mean()
mean_actual = actual_data.loc[post_start:post_end].mean()
mean_buzz = buzz_data.loc[post_start:post_end].mean().values[0]
ripple_rate = (mean_actual - mean_pred) / mean_buzz
ripple_rate_per_art = ripple_rate / num_of_article
print(f"{label} 予測: {mean_pred:.1f}, 実測: {mean_actual:.1f}, バズ平均: {mean_buzz:.1f}, 波及率: {ripple_rate:.4%}, 一記事当たり: {ripple_rate_per_art:.4%}")
return ripple_rate
def calculate_simple_ripple_effect(df_master,df_data,control_list,impact_hash,pre_period,post_period):
impact = np.mean(df_data[impact_hash].loc[post_period[0]:post_period[1]].mean())
print(df_master[df_master['ハッシュ値'].isin(control_list)]['ページ名'])
prev_impact = np.mean(df_data[control_list].sum(axis=1).loc[pre_period[0]:pre_period[1]].mean())
after_impact = np.mean(df_data[control_list].sum(axis=1).loc[post_period[0]:post_period[1]].mean())
print(df_master[df_master['ハッシュ値'].isin(control_list)]['ページ名'])
print(f'バズ前の記事の一日平均表示:{prev_impact}')
print(f'バズ後の記事の一日平均表示:{after_impact}')
print(f'バズ記事の一日平均表示:{impact}')
print(f'平均波及効果:{(after_impact - prev_impact)/impact:.4%}')
print(f'一記事あたりの平均波及効果:{(after_impact - prev_impact)/impact/len(control_list):.4%}')
return (after_impact - prev_impact)/impact
def main():
df_data = read_data.read_data('表示回数')
df_master = pd.read_csv('./data/ハッシュ値とページ名.csv')
df_master['投稿日付'] = pd.to_datetime(df_master['投稿日付'].astype(str))
plt.figure()
plt.plot(df_data.loc['2025-12-15':'2026-03-19'])
plt.plot(df_data.loc['2025-12-15':'2026-03-19'].sum(axis=1),'--')
# 日付ラベルを45度回転させる
plt.xticks(rotation=45)
# もしラベルが端で切れる場合はこれを足す
plt.tight_layout()
plt.show()
# 最初のバズ投稿の波及効果の推定(初歩的な統計での比較)
first_impact_day = dt.datetime(year=2026,month=1,day=2)
pre_period = ['2025-12-18', '2026-01-01'] # バズ前の安定期
post_period = ['2026-01-02', '2026-01-15'] # バズ発生後の観測期
control_list = df_master[df_master['投稿日付'] < first_impact_day - dt.timedelta(days=14)]['ハッシュ値']
impact_hash = df_master[df_master['ページ名']==FIRST_BUZZ_TITLE]['ハッシュ値']
calculate_simple_ripple_effect(df_master,df_data,control_list,impact_hash,pre_period,post_period)
# 最初のバズ投稿のCausal Impactによる比較
# y: 波及を見たい記事の合計, X: 鏡にする記事
mirror_hash = df_master[df_master['ページ名']==MIRROR_ARTICLE_TITLE]['ハッシュ値']
ci_data = pd.concat([
df_data[control_list].sum(axis=1).rename('y'), # ターゲット (1次元)
df_data[mirror_hash] # 共変量 (複数次元: DataFrame)
], axis=1) # 期間の設定
ci = CausalImpact(ci_data, pre_period, post_period)
# 4. 可視化
# 3つのグラフ(実測と予測、個別の増分、累積の増分)が表示されます
ci.plot()
plt.show()
# 5. 結果のレポート表示
print(ci.summary())
# より詳細な文章での解説が見たい場合
# print(ci.summary(output='report'))
# 1. バズ期間中の「純増PV(差分)」の合計
# これが「波及によって生まれた追加のアクセス数」です
df_result = ci.inferences
calculate_ripple_effect(df_result,df_data[control_list].sum(axis=1),df_data[impact_hash],post_period,len(control_list),label='一回目')
# 二回目のバズ投稿のCausal Impactによる比較
second_impact_day = dt.datetime(year=2026,month=3,day=8)
pre_period = ['2026-02-21', '2026-03-07'] # バズ前の安定期
post_period = ['2026-03-08', '2026-03-19'] # バズ発生後の観測期
control_list = df_master[df_master['投稿日付'] < second_impact_day - dt.timedelta(days=14)]['ハッシュ値']
impact_hash = df_master[df_master['ページ名']==SECOND_BUZZ_TITLE]['ハッシュ値']
calculate_simple_ripple_effect(df_master,df_data,control_list,impact_hash,pre_period,post_period)
mirror_hash = df_master[df_master['ページ名']==MIRROR_ARTICLE_TITLE]['ハッシュ値']
print(df_master[df_master['ハッシュ値'].isin(control_list)]['ページ名'])
ci_data = pd.concat([
df_data[control_list].sum(axis=1).rename('y'), # ターゲット (1次元)
df_data[mirror_hash] # 共変量 (複数次元: DataFrame)
], axis=1) # 期間の設定
ci = CausalImpact(ci_data, pre_period, post_period)
# 4. 可視化
# 3つのグラフ(実測と予測、個別の増分、累積の増分)が表示されます
ci.plot()
plt.show()
# 5. 結果のレポート表示
print(ci.summary())
# より詳細な文章での解説が見たい場合
# print(ci.summary(output='report'))
# 1. バズ期間中の「純増PV(差分)」の合計
df_result = ci.inferences
calculate_ripple_effect(df_result,df_data[control_list].sum(axis=1),df_data[impact_hash],post_period,len(control_list),label='二回目')
pass
if __name__ == "__main__":
main()


