下巻 第2章 解答例#

ここでは、 本書の学習内容の定着 を目的とした練習問題とその解答・解説を掲載します。 なお、問題の性質上、本書で取り上げた処理と重複することがあります。 ご了承ください。

前提#

以下のように、ライブラリのインポートと変数の定義が完了していることを前提とします。

Hide code cell content
# pathlibモジュールのインポート
# ファイルシステムのパスを扱う
from pathlib import Path

# typingモジュールからの型ヒント関連のインポート
# 関数やクラスの引数・返り値の型を注釈するためのツール
from typing import Any, Dict, List, Optional, Union

# numpy:数値計算ライブラリのインポート
# npという名前で参照可能
import numpy as np

# pandas:データ解析ライブラリのインポート
# pdという名前で参照可能
import pandas as pd

# plotly.expressのインポート
# インタラクティブなグラフ作成のライブラリ
# pxという名前で参照可能
import plotly.express as px

# plotly.figure_factoryのインポート
# 高度なプロットとデータ可視化のためのユーティリティ
# ffという名前で参照可能
import plotly.figure_factory as ff

# plotly.graph_objectsからFigureクラスのインポート
# 型ヒントの利用を主目的とする
from plotly.graph_objects import Figure

# plotly.subplotsからmake_subplotsのインポート
# 複数のサブプロットを含む複合的な図を作成する際に使用
from plotly.subplots import make_subplots
Hide code cell content
# マンガデータ保存ディレクトリのパス
DIR_CM = Path("../../../data/cm/input")
# アニメデータ保存ディレクトリのパス
DIR_AN = Path("../../../data/an/input")
# ゲームデータ保存ディレクトリのパス
DIR_GM = Path("../../../data/gm/input")
Hide code cell content
# 読み込み対象ファイル名の定義

# Comic Episode関連のファイル名
FN_CE = "cm_ce.csv"

# Anime Episode関連のファイル名
FN_AE = "an_ae.csv"

# PacKaGeとPlatForm関連のファイル名
FN_PKG_PF = "gm_pkg_pf.csv"
Hide code cell content
# plotlyの描画設定の定義

# plotlyのグラフ描画用レンダラーの定義
# Jupyter Notebook環境のグラフ表示に適切なものを選択
RENDERER = "plotly_mimetype+notebook"
Hide code cell content
# Okabe and Ito 2008
# https://jfly.uni-koeln.de/color/#pallet
OKABE_ITO = [
    "#000000",  # 黒 (Black)
    "#E69F00",  # 橙 (Orange)
    "#56B4E9",  # 薄青 (Sky Blue)
    "#009E73",  # 青緑 (Bluish Green)
    "#F0E442",  # 黄色 (Yellow)
    "#0072B2",  # 青 (Blue)
    "#D55E00",  # 赤紫 (Vermilion)
    "#CC79A7",  # 紫 (Reddish Purple)
]
Hide code cell content
# 各プラットフォームのタイプをまとめた辞書
pfname2type = {
    "プレイステーション・ポータブル": "携帯型",
    "プレイステーション": "据置型",
    "プレイステーションVita": "携帯型",
    "プレイステーション3": "据置型",
    "プレイステーション2": "据置型",
}

また、本書中で取り上げた以下の関数も、同様に利用可能とします。

Hide code cell source
def show_fig(fig: Figure) -> None:
    """
    所定のレンダラーを用いてplotlyの図を表示
    Jupyter Bookなどの環境での正確な表示を目的とする

    Parameters
    ----------
    fig : Figure
        表示対象のplotly図

    Returns
    -------
    None
    """

    # 図の周囲の余白を設定
    # t: 上余白
    # l: 左余白
    # r: 右余白
    # b: 下余白
    fig.update_layout(margin=dict(t=25, l=25, r=25, b=25))

    # 所定のレンダラーで図を表示
    fig.show(renderer=RENDERER)
Hide code cell content
def create_distplot(
    df: pd.DataFrame,
    x: str,
    color: str = None,
    show_hist: bool = False,
    show_rug: bool = False,
    **kwargs: Any
) -> Figure:
    """
    データフレームから密度プロットとヒストグラムを作成する

    Parameters
    ----------
    df : pd.DataFrame
        プロットするデータを含むデータフレーム
    x : str
        密度プロットの描画対象とするカラム名
    color : str, optional
        データを分割する基準とするカラム名、指定しない場合はx列の全データを用いる
    show_hist : bool, optional
        ヒストグラムを表示するか否か、デフォルトはFalse
    show_rug : bool, optional
        ラグプロットを表示するか否か、デフォルトはFalse
    **kwargs
        ff.create_distplotに渡すその他のキーワード引数

    Returns
    -------
    Figure
        作成されたプロットのFigureオブジェクト
    """

    if color:
        # colorカラムの値でデータをグループ分け
        grouped = df.groupby(color)

        # 各グループのxカラムのデータをリストに格納、可視化用に逆順に並び替え
        hist_data = [group[x].values for _, group in grouped][::-1]

        # 各グループの名前(colorカラムの値)をラベルとしてリストに格納、可視化用に逆順に並び替え
        labels = [str(name) for name, _ in grouped][::-1]

        # 密度プロットとヒストグラムを作成
        fig = ff.create_distplot(
            hist_data, labels, show_hist=show_hist, show_rug=show_rug, **kwargs
        )
    else:
        # colorが指定されていない場合はx列の全データを用いる
        hist_data = [df[x].values]

        # 密度プロットを作成(ラベルはxを指定)
        fig = ff.create_distplot(
            hist_data,
            group_labels=[x],
            show_hist=show_hist,
            show_rug=show_rug,
            **kwargs
        )

    # x軸のタイトルをxに変更
    fig.update_xaxes(title=x)

    # y軸のタイトルを"確率密度"に変更
    fig.update_yaxes(title="確率密度")

    # 作成されたプロットを返す
    return fig

以下のようにデータを読み込み済みとします。

Hide code cell content
# マンガデータの読み込み
df_ce = pd.read_csv(DIR_CM / FN_CE)
# アニメデータの読み込み
df_ae = pd.read_csv(DIR_AN / FN_AE)
# ゲームデータの読み込み
df_pkg_pf = pd.read_csv(DIR_GM / FN_PKG_PF)

基礎 問題1:アニメ作品の合計話数の分布#

関連セクション: ヒストグラム

本書では、アニメ作品ごとの合計話数のヒストグラムを作成し、表示範囲を100話以下に制限して分布を観察しました。表示範囲を変えると、分布の見え方はどのように変わるでしょうか。

  • df_aeを用いて、アニメ作品ごとの合計話数を集計してください

  • 合計話数のヒストグラムを作成し、表示範囲を 50話 以下に制限してください

Hide code cell source
# アニメ作品ごとに話数を集計
df_an = df_ae.groupby(["acid", "acname"])["aeid"].nunique().reset_index(name="合計話数")

# 合計話数のヒストグラムを作成
fig = px.histogram(df_an, x="合計話数")

# Y軸のタイトルを更新
fig.update_yaxes(title="該当アニメ作品数")

# X軸の表示範囲を0〜50話に制限
fig.update_xaxes(range=[0, 50])

# ヒストグラムを表示
show_fig(fig)

解説

本文と同様の処理で、表示範囲のみを変更した問題です。

50話以下に範囲を絞ることで、1クール(12〜13話)や2クール(24〜26話)のピークがより明確に観察できます。 表示範囲を適切に調整することで、分布の特徴的なパターンを強調できることがわかります。

関連セクション: 詳しくはヒストグラムを参照してください。

基礎 問題2:ゲーム価格の累積分布#

関連セクション: ヒストグラム

本書では、ゲームパッケージの価格の累積ヒストグラムを作成し、10,000円以下の範囲で累積分布を確認しました。表示範囲を広げると、価格分布の全体像がより明確になります。

  • df_pkg_pfを用いて、ゲームパッケージの価格の累積ヒストグラムを作成してください

  • 表示範囲を 15,000円 以下に制限してください

Hide code cell source
# ゲームパッケージの価格の累積ヒストグラムを作成
fig = px.histogram(df_pkg_pf, x="price", cumulative=True)

# Y軸のタイトルを更新
fig.update_yaxes(title="累積ゲームパッケージ数")

# X軸のタイトルを更新
fig.update_xaxes(title="価格")

# X軸の表示範囲を0〜15,000円に制限
fig.update_xaxes(range=[0, 15000])

# ヒストグラムを表示
show_fig(fig)

解説

本文と同様の処理で、表示範囲のみを変更した問題です。

累積ヒストグラムは「ある価格以下のパッケージが何件あるか」を視覚的に把握するのに適しています。 15,000円以下に範囲を広げることで、10,000円を超える価格帯のパッケージがどの程度存在するかも確認できます。

関連セクション: 詳しくはヒストグラムを参照してください。

標準 問題3:週刊少年ジャンプのページ数分布#

関連セクション: ヒストグラム

本文では、4誌すべてのページ数分布をファセット表示で比較しました。特定の雑誌に絞り込むと、その雑誌固有の特徴がより詳細に観察できます。

  • df_ceから週刊少年ジャンプのデータのみに絞り込んでください

  • 一話あたりのページ数(50ページ以下)のヒストグラムを作成してください

  • ビン数を25に設定してください

Hide code cell source
# 週刊少年ジャンプのデータのみに絞り込み
df_jump = df_ce[df_ce["mcname"] == "週刊少年ジャンプ"]

# 50ページ以下のデータに絞り込み
df_jump = df_jump[df_jump["pages"] <= 50]

# ページ数のヒストグラムを作成(ビン数25)
fig = px.histogram(df_jump, x="pages", nbins=25)

# 軸タイトルを更新
fig.update_xaxes(title="一話あたりのページ数")
fig.update_yaxes(title="該当話数")

# ヒストグラムを表示
show_fig(fig)

解説

ブールインデックスによるデータの絞り込みと、ヒストグラムの作成を組み合わせた問題です。

週刊少年ジャンプに絞ることで、19ページ付近に明確なピークがあることがわかります。 他の雑誌ではどうなるでしょうか?

関連セクション: 詳しくはヒストグラムを参照してください。

標準 問題4:プレイステーション2の価格分布#

関連セクション: 箱ひげ図

本文の箱ひげ図では、プレイステーションシリーズの価格分布を中央値や四分位範囲で要約しました。しかし、箱ひげ図では分布の詳細な形状は見えません。特定のプラットフォームに絞り、ヒストグラムで分布を詳しく観察してみましょう。

  • df_pkg_pfからプレイステーション2のデータのみに絞り込んでください

  • 価格のヒストグラムを作成してください

  • 表示範囲を10,000円以下に制限してください

Hide code cell source
# プレイステーション2のデータのみに絞り込み
df_ps2 = df_pkg_pf[df_pkg_pf["pfname"] == "プレイステーション2"]

# 価格のヒストグラムを作成
fig = px.histogram(df_ps2, x="price")

# 軸タイトルを更新
fig.update_xaxes(title="価格", range=[0, 10000])
fig.update_yaxes(title="該当ゲームパッケージ数")

# ヒストグラムを表示
show_fig(fig)

解説

ブールインデックスによるデータの絞り込みと、ヒストグラムの作成を組み合わせた問題です。

箱ひげ図では中央値付近に分布が集中していることはわかりますが、ヒストグラムで見ると6,000〜7,000円付近に明確なピークがあることがわかります。 このように、同じデータでも可視化手法を変えることで、異なる側面の情報を得ることができます。

関連セクション: 詳しくは箱ひげ図を参照してください。

発展 問題5:ページ数分布の話数別変化#

関連セクション: ヒストグラム

連載の長さによって、1話あたりのページ数にどのような違いがあるでしょうか。読み切り作品(1話完結)と短期連載では、ページ数の決まり方が異なるかもしれません。

  • ccid(作品ID)ごとに合計話数を算出し、df_ce に結合してください

  • 合計話数が「1話」から「10話」までの作品に限定し、ページ数(50ページ以下)のヒストグラムをファセット表示で比較してください

  • ファセットは合計話数を基準に分けてください

Hide code cell source
# 作品IDごとに各話数をカウントし、集計用のDataFrameを作成
df_cnt = df_ce.groupby("ccid")["ceid"].nunique().reset_index(name="合計話数")

# 元のデータフレームに作品ごとの合計話数を結合
df_ce_merged = pd.merge(df_ce, df_cnt, on="ccid")

# 合計話数が1から10話の間、かつページ数が50以下のデータに限定
df_target = df_ce_merged[
    (df_ce_merged["合計話数"] <= 10) & (df_ce_merged["pages"] <= 50)
]

# 合計話数でファセット分けしたヒストグラムを作成(2列折り返しで表示)
fig = px.histogram(
    df_target.sort_values("合計話数"),
    x="pages",
    facet_col="合計話数",
    facet_col_wrap=2,
    height=800,
    labels={"pages": "ページ数"},
)

# Y軸のラベルを「該当作品数」に設定
fig.update_yaxes(title_text="該当作品数")

# ファセットタイトルの「合計話数=1」などのラベルを数字のみにクリーンアップ
fig.for_each_annotation(lambda a: a.update(text=f"{a.text.split('=')[-1]}話"))

# 可視化結果を表示
show_fig(fig)

解説

合計話数を軸に、ページ数という量的変数の分布がどう変化するかをファセットで比較する問題です。

1話完結の読み切り作品では31ページや45ページといった比較的大きなページ数にピークが見られますが、連載回数が増えるにつれて(とくに合計8話あたりから)、徐々に19〜20ページ付近の「連載標準」のピークが支配的になっていく様子が視覚的に捉えられます。 ヒストグラムを並べることで、単なる平均値の比較では見落としてしまう性質を、データから再発見することが可能になります。

関連セクション: 詳しくはヒストグラムを参照してください。

発展 問題6:プレイステーションシリーズの価格帯比較#

関連セクション: 箱ひげ図

ゲームプラットフォームの世代交代と販売価格の関係を整理しましょう。プレイステーションシリーズ5機種を対象に、「据置型」と「携帯型」で価格分布がどう異なるかを比較します。

  • 辞書pfname2typeを用いて各機種を「据置型」と「携帯型」に分類してください

  • 横軸を分類、縦軸を販売価格とした箱ひげ図を作成してください

Hide code cell source
# 辞書を用いて分類列を作成し、対象機種のみをフィルタリング
df_ps = df_pkg_pf.copy()
df_ps["分類"] = df_ps["pfname"].map(pfname2type)
df_ps = df_ps.dropna(subset=["分類"])

# 据置・携帯の分類をX軸、販売価格をY軸にした箱ひげ図を作成
fig = px.box(df_ps, x="分類", y="price")

# 表示範囲を0〜20,000円に制限し、日本語ラベルを付与
fig.update_yaxes(range=[0, 20000], title_text="販売価格")
fig.update_xaxes(title_text="プラットフォーム分類")

# 表示
show_fig(fig)

解説

箱ひげ図を用いて、特定のシリーズ内における価格戦略の差異を要約的に比較する問題です。

本章で学んだ通り、箱ひげ図は詳細な分布形状こそ捨象しますが、中央値や四分位範囲を比べることで「据置型は携帯型より価格レンジが高い」といった傾向を迅速に把握するのに適しています。 プレイステーションシリーズに絞ることで、同一メーカー内での「据置機と携帯機の価格差」に関する示唆を得やすくなります。

関連セクション: 詳しくは箱ひげ図を参照してください。

応用 問題7:平日・土日の話数分布の推移#

関連セクション: リッジラインプロット

本章では、深夜アニメの台頭によりクール化が進んだ可能性が示唆されました。この傾向を「平日(月〜金)」と「土日」の放送枠で分けて分析してみましょう。

  • 曜日情報を抽出し、「平日」と「土日」に分類してください

  • 平日と土日の両方で放送データが存在する作品は除外 してください(厳密な比較のため)

  • 1990年代、2000年代、2010年代の作品について、年代ごとの合計話数分布(100話以下)をリッジラインプロットで比較してください

Hide code cell source
# 放送日をdatetime型に変換し、10年単位の年代列と曜日番号を取得
df_ae["date"] = pd.to_datetime(df_ae["date"])
df_ae["decade"] = (df_ae["date"].dt.year // 10 * 10).astype(str)
df_ae["day_of_week"] = df_ae["date"].dt.dayofweek  # 0:月 ... 6:日

# 曜日番号を種別ラベル(平日・土日)に変換する関数を適用
df_ae["曜日種別"] = df_ae["day_of_week"].apply(lambda x: "土日" if x >= 5 else "平日")

# 各作品(acid)が「平日」と「土日」の何種類に属しているかをカウント
day_type_counts = df_ae.groupby("acid")["曜日種別"].nunique()

# 1種類(平日のみ、または土日のみ)に属する作品IDだけを抽出
pure_acids = day_type_counts[day_type_counts == 1].index

# 両方にまたがる作品を除外し、主要3年代かつ100話以下のデータに限定
df_pure_ae = df_ae[df_ae["acid"].isin(pure_acids)]
df_an_pivot = (
    df_pure_ae.groupby(["decade", "曜日種別", "acid"])["aeid"]
    .nunique()
    .reset_index(name="n_ae")
)
df_an_sub = df_an_pivot[
    (df_an_pivot["n_ae"] <= 100)
    & (df_an_pivot["decade"].isin(["1990", "2000", "2010"]))
]

# リッジラインプロットをファセット展開して作成
fig = px.violin(
    df_an_sub.sort_values("decade"),
    y="decade",
    x="n_ae",
    facet_col="曜日種別",
    orientation="h",
    points=False,
)

# 各プロットを上側のみ描画(side='positive')し、バンド幅を調整して詳細な分布を表示
fig.update_traces(side="positive", bandwidth=1, width=3)

# 各軸の日本語ラベル設定と、ファセットタイトルのクリーンアップ
fig.update_xaxes(title_text="合計話数")
fig.update_yaxes(title_text="年代")
fig.for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1]))

# 可視化結果を表示
show_fig(fig)

解説

リッジラインプロットの形式を応用し、時間軸(年代)と属性軸(曜日種別)を多角的に比較する問題です。

共通する作品を除外するという前処理を挟むことで、純粋に「平日枠のアニメ」と「土日枠のアニメ」の性質の違いを浮き彫りにしています。 平日枠、土日枠いずれにおいても、1クール作品(合計話数が12-13付近)が増えていく様子が観察できます。 とは言え、平日と比較して、土日のほうが比較的4クール作品(合計話数が50-52付近)が残っているようです。

関連セクション: 詳しくはリッジラインプロットを参照してください。

応用 問題8:最終回の掲載位置分布#

関連セクション: 密度プロット

マンガ作品の「最終回」は、雑誌のどのあたりに掲載される傾向があるでしょうか。巻頭カラーで華々しく飾られる作品もあれば、そうでない作品もありそうです。

  • 各作品の「最後の一話」を特定し、その掲載位置(page_start_position)を抽出してください

  • ただし、データセット末尾の作品は連載中の可能性があります(右側打ち切り)。各雑誌の最新巻号に掲載されていない作品のみ を対象としてください[1]

  • 雑誌ごとの分布を密度プロットで比較してください

Hide code cell source
# 各雑誌のデータ上の「最終発行日」を特定
max_date_mag = df_ce.groupby("mcname")["date"].max()

# 各作品(ccid)ごとの「最終掲載日」を特定
max_date_series = df_ce.groupby("ccid")["date"].max().reset_index()

# 作品IDと雑誌名の対応関係を抽出
ccid_mc = df_ce[["ccid", "mcname"]].drop_duplicates()

# 作品ごとの最終掲載日に、対応する雑誌名とその雑誌の最終発行日を結合
df_check = pd.merge(max_date_series, ccid_mc, on="ccid")
df_check = pd.merge(df_check, max_date_mag.rename("mag_max_date"), on="mcname")

# 「作品の最終掲載日 < 雑誌の最終発行日」を満たす作品IDを抽出(完結済みと判定)
finished_eps = df_check[df_check["date"] < df_check["mag_max_date"]]

# 抽出した「完結作品」の「最終回」レコードのみを元のデータから取得
df_final_ep = pd.merge(df_ce, finished_eps[["ccid", "date"]], on=["ccid", "date"])

# 密度プロットの作成
# 横軸に掲載位置、縦軸に確率密度、雑誌別に色をわけて重畳表示
fig = create_distplot(
    df_final_ep,
    x="page_start_position",
    color="mcname",
    colors=OKABE_ITO,
)

# 軸タイトルの変更
fig.update_layout(
    xaxis_title="最終話の掲載位置(0:巻頭, 1:巻末)",
)

# 可視化結果を表示
show_fig(fig)

解説

密度プロットを用いて、雑誌ごとの「最終回の掲載位置」を比較する問題です。 右側打ち切り(Right-censored)という課題を、 最新巻号で連載中の作品を除外することで緩和[1]しています。

全雑誌に共通する特徴として、巻頭付近で最終回を迎える作品は稀 のようです。意外ですね。 そのうえで雑誌ごとに分布に特徴があらわれており、非常に興味深い結果となりました。 たとえば、 週刊少年チャンピオン は巻末付近にのみピークを持ちますが、 週刊少年マガジン は中央付近 [2] にもピークが見られます。

関連セクション: 詳しくは密度プロットを参照してください。