Qiitaの投稿パフォーマンス分析：リアクション数の比較

# はじめに

最近Qiita投稿を多めに行っていたので、この半年間のパフォーマンスを、リアクション数に注目して分析してみました。

# 比較すべき指標の検討
ある期間における自分の投稿が、同様の期間におけるQiita全体と比べて、良く評価されていたかどうかを分析したいです。
ナイーブにはリアクション数（いいね＋ストック）が多ければ、良く評価されたといえそうです。

リアクション数の指標として、例えば以下が考えられます。

1. 中央値
   - 外れ値の影響を受けにくい
   - Qiitaの記事の大半のリアクション数は0なので、中央値も0になり、比較に使えない可能性も高い

2. 平均値
   - 外れ値の影響を受けやすい。
   - Qiitaの記事は大半が0で、バズるものは非常に高いリアクション数となるので、実質的に、バズ記事があったかどうかの指標になってしまう。

3. n以上のリアクションがついた記事の割合
   - バズるかどうかは運次第
   - しかし、0でない数のリアクションがついていれば、少なくとも全くダメダメではなかったと言える
   - 中央値や平均値に比べると筋が良さそうな気がする

4. 上位10%の分析
   - Qiitaの記事にはほとんど自分用のメモのようなものも含まれるため、そういったものを除いて比較する意図
   - ただし求める統計値が中央値や平均値だと結局、全体を分析するときと同じ課題は生じうる

他にもあるかもしれませんが、一旦上記くらいのものについて実際の値を眺めてみることにします。


# 分析と結果
2024/10/01から分析実施日の2025/03/20に投稿された記事を分析対象としました。
コードは末尾に示します。

## 基本統計量

### 全体の統計
- 全記事数: 10,000件
- 中央値: 0.00
- 平均値: 4.68
- 上位10%の閾値: 5.00
- 上位10%の中央値: 10.00
- 上位10%の平均: 39.78
- 上位10%の記事数: 1,028件

### 自分の投稿の統計
- 全記事数: 49件
- 中央値: 0.00
- 平均値: 0.82
- 上位10%の閾値: 2.00
- 上位10%の中央値: 2.00
- 上位10%の平均: 2.45
- 上位10%の記事数: 11件

## リアクション数の分布

### 1以上のリアクション
- 全体: 43.89%
- 自分の投稿: 48.98%

### 2以上のリアクション
- 全体: 25.66%
- 自分の投稿: 22.45%

### 3以上のリアクション
- 全体: 17.56%
- 自分の投稿: 4.08%

# 考察
リアクション数1以上の記事の割合はと中央値を除き、自分の投稿よりもQiita全体のほうがリアクション数の統計値が良いという結果でした。

リアクション数1以上の記事の割合は自分の記事が48.98%、Qiita全体が43.89%で、自分の記事のほうが高かったです。
ただし、微妙な差であり、2以上、３以上ではQiita全体のほうが優れているので、誤差の範囲ともいえそうです。

中央値はどちらも0でした。やはりリアクション数0の記事が多いため、全体の中央値では傾向は見えにくそうです。
上位10％に限定すると、自分の記事が2.45、Qiita全体が10.0と中央値でも差がついており、比較に使えそうな気配もあります。

平均値は自分の記事が0.82、Qiita全体が4.68でした。この半年間の自分の記事のリアクション数の最大値は6であり、バズったと呼べるものはなかったため、この指標では不利になった可能性があります。

全体的には、この半年間の自分の投稿はリアクション数という観点では、Qiita全体の投稿の傾向よりも低いパフォーマンスと判断するのが妥当そうです。
少なくともこの半年間は粗製乱造気味だったかもしれません。反省したいと思います。


# 参考：分析に使用したコード

## リアクション数の取得

```python
import csv
import random
import sys

import requests
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

API_URL = "https://qiita.com/api/v2/items"


class Settings(BaseSettings):
    qiita_token: str = Field(..., description="Qiitaのアクセストークン")
    start_date: str = Field(..., description="開始日（YYYY-MM-DD形式）")
    end_date: str = Field(..., description="終了日（YYYY-MM-DD形式）")
    username: str | None = Field(default=None, description="ユーザー名（オプション）")
    sample_size: int = Field(
        default=1000, description="サンプル件数（デフォルト1000件）"
    )
    output_file: str = Field(
        default="counts.csv", description="出力ファイル名（デフォルトcounts.csv）"
    )

    model_config = SettingsConfigDict(
        env_file=".env",
        case_sensitive=False,
        cli_parse_args=True,
        extra="ignore",
    )


def get_articles(query, page, per_page, headers):
    params = {"page": page, "per_page": per_page, "query": query}
    r = requests.get(API_URL, params=params, headers=headers)
    if r.status_code != 200:
        print(f"Error fetching page {page}: {r.text}")
        return []
    return r.json()


def find_valid_last_page(query, headers):
    """二分探索で実際に記事が存在する最後のページを見つける"""
    left = 1
    right = 100  # Qiita APIの制限（100ページ）
    last_valid_page = 1

    # まず1ページ目で記事が取得できるか確認
    articles = get_articles(query, 1, 100, headers)
    if not articles:
        return 0

    while left <= right:
        mid = (left + right) // 2
        articles = get_articles(query, mid, 100, headers)

        if articles:  # 記事が存在する場合
            last_valid_page = mid
            left = mid + 1
        else:  # 記事が存在しない場合
            right = mid - 1

    return last_valid_page


def main():
    settings = Settings()

    QIITA_TOKEN = settings.qiita_token
    if not QIITA_TOKEN:
        print("エラー: 環境変数 QIITA_TOKEN が設定されていません")
        sys.exit(1)

    HEADERS = {"Authorization": f"Bearer {QIITA_TOKEN}"}

    query = f"created:>={settings.start_date} created:<={settings.end_date}"
    if settings.username:
        query = f"{query} user:{settings.username}"
    print("Query:", query)

    # 実際に記事が存在する最後のページを見つける
    last_valid_page = find_valid_last_page(query, HEADERS)
    if last_valid_page == 0:
        print("指定された期間内に記事が見つかりませんでした。")
        sys.exit(1)

    per_page = 100
    total_possible = last_valid_page * per_page

    if settings.sample_size >= total_possible:
        pages_to_fetch = list(range(1, last_valid_page + 1))
    else:
        num_pages_needed = (settings.sample_size // per_page) + 1
        if num_pages_needed > last_valid_page:
            pages_to_fetch = list(range(1, last_valid_page + 1))
        else:
            pages_to_fetch = random.sample(
                range(1, last_valid_page + 1), num_pages_needed
            )

    print("ランダムに選んだページ番号:", pages_to_fetch)

    collected_articles = []
    for page in pages_to_fetch:
        articles = get_articles(query, page, per_page, HEADERS)
        print(f"ページ {page} から {len(articles)} 件取得")
        collected_articles.extend(articles)
        if len(collected_articles) >= settings.sample_size:
            break

    if len(collected_articles) == 0:
        print("記事が見つかりませんでした。期間やクエリを確認してください。")
        sys.exit(1)

    if len(collected_articles) > settings.sample_size:
        collected_articles = random.sample(collected_articles, settings.sample_size)

    # カウントの集計
    counts = {"likes": {}, "stocks": {}, "reactions": {}}

    for article in collected_articles:
        like_count = article.get("likes_count", 0)
        stock_count = article.get("stocks_count", 0)
        reaction_count = like_count + stock_count

        # 各カウントの頻度を集計
        counts["likes"][like_count] = counts["likes"].get(like_count, 0) + 1
        counts["stocks"][stock_count] = counts["stocks"].get(stock_count, 0) + 1
        counts["reactions"][reaction_count] = (
            counts["reactions"].get(reaction_count, 0) + 1
        )

    # 全てのカウント値を取得してソート
    all_values = sorted(
        set(
            list(counts["likes"].keys())
            + list(counts["stocks"].keys())
            + list(counts["reactions"].keys())
        )
    )

    # 頻度の集計
    with open(settings.output_file, "w", newline="") as csvfile:
        writer = csv.writer(csvfile)
        writer.writerow(["value", "likes", "stocks", "reactions"])

        for value in all_values:
            writer.writerow(
                [
                    value,
                    counts["likes"].get(value, 0),
                    counts["stocks"].get(value, 0),
                    counts["reactions"].get(value, 0),
                ]
            )

    print(f"{settings.output_file} を保存しました。")


if __name__ == "__main__":
    main()
```

使い方は以下のとおりです。環境変数、または、CLI引数で指定します。
usernameは未指定の場合はqiita全体となります。

```
% uv run qiita_random_count.py -h
usage: qiita_random_count.py [-h] [--qiita_token str] [--start_date str] [--end_date str] [--username {str,null}] [--sample_size int] [--output_file str]

options:
  -h, --help            show this help message and exit
  --qiita_token str     Qiitaのアクセストークン (required)
  --start_date str      開始日（YYYY-MM-DD形式） (required)
  --end_date str        終了日（YYYY-MM-DD形式） (required)
  --username {str,null}
                        ユーザー名（オプション） (default: null)
  --sample_size int     サンプル件数（デフォルト1000件） (default: 1000)
  --output_file str     出力ファイル名（デフォルトcounts.csv） (default: counts.csv)
```

実行すると以下のようなcsvが出力されます。
以下の実行ではQIITA_TOKENは環境変数に登録済みでした

```
% uv run qiita_random_count.py --username USERNAME --output_file counts.csv --start_date 2020-01-01 --end_date 2025-03-31 --sample_size 10000
```
```csv:counts.csv
value,likes,stocks,reactions
0,6503,6921,5611
1,1762,1705,1823
2,647,555,810
3,295,246,446
4,177,113,282
5,100,68,197
...
```

### 指標の計算
```python
import numpy as np
import pandas as pd
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    filepath: str = Field(..., description="分析対象のCSVファイルのパス")

    model_config = SettingsConfigDict(
        env_file=".env",
        case_sensitive=False,
        cli_parse_args=True,
        extra="ignore",
    )


def analyze_reactions(file_path):
    df = pd.read_csv(file_path)

    # リアクション数の分布を計算
    reactions = []
    for _, row in df.iterrows():
        reactions.extend([row["value"]] * row["reactions"])
    reactions = np.array(reactions)

    # 基本統計量
    total_articles = len(reactions)
    median = np.median(reactions)
    mean = np.mean(reactions)

    # 上位10%の閾値を計算
    top_10_threshold = np.percentile(reactions, 90)
    top_10_articles = reactions[reactions >= top_10_threshold]
    top_10_mean = np.mean(top_10_articles)
    top_10_median = np.median(top_10_articles)

    # 1以上、2以上、3以上の記事の割合を計算
    articles_with_one_or_more = np.sum(reactions > 0)
    articles_with_two_or_more = np.sum(reactions > 1)
    articles_with_three_or_more = np.sum(reactions > 2)

    one_or_more_ratio = articles_with_one_or_more / total_articles * 100
    two_or_more_ratio = articles_with_two_or_more / total_articles * 100
    three_or_more_ratio = articles_with_three_or_more / total_articles * 100

    return {
        "reactions": reactions,
        "total_articles": total_articles,
        "median": median,
        "mean": mean,
        "top_10_threshold": top_10_threshold,
        "top_10_mean": top_10_mean,
        "top_10_median": top_10_median,
        "top_10_count": len(top_10_articles),
        "one_or_more_ratio": one_or_more_ratio,
        "two_or_more_ratio": two_or_more_ratio,
        "three_or_more_ratio": three_or_more_ratio,
    }


def main():
    # 設定の読み込み
    settings = Settings()

    # データを分析
    stats = analyze_reactions(settings.filepath)

    print(f"=== {settings.filepath}の統計 ===")
    print(f"全記事数: {stats['total_articles']}")
    print(f"中央値: {stats['median']:.2f}")
    print(f"平均値: {stats['mean']:.2f}")
    print(f"上位10%の閾値: {stats['top_10_threshold']:.2f}")
    print(f"上位10%の平均: {stats['top_10_mean']:.2f}")
    print(f"上位10%の中央値: {stats['top_10_median']:.2f}")
    print(f"上位10%の記事数: {stats['top_10_count']}")
    print(f"\n1以上リアクションがついた記事の割合: {stats['one_or_more_ratio']:.2f}%")
    print(f"2以上リアクションがついた記事の割合: {stats['two_or_more_ratio']:.2f}%")
    print(f"3以上リアクションがついた記事の割合: {stats['three_or_more_ratio']:.2f}%")


if __name__ == "__main__":
    main()
``` 

使い方は以下のとおりです。

```
% uv run analyze_reactions.py -h                
usage: analyze_reactions.py [-h] [--filepath str]

options:
  -h, --help      show this help message and exit
  --filepath str  分析対象のCSVファイルのパス (required)
```

実行すると以下のような出力が得られます。

```
% uv run analyze_reactions.py --filepath counts.csv 
=== counts_all_6m.csvの統計 ===
全記事数: 10000
中央値: 0.00
平均値: 4.68
上位10%の閾値: 5.00
上位10%の平均: 39.78
上位10%の中央値: 10.00
上位10%の記事数: 1028

1以上リアクションがついた記事の割合: 43.89%
2以上リアクションがついた記事の割合: 25.66%
3以上リアクションがついた記事の割合: 17.56%
```