0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Plotlyで作る!3D Surface × Violin Plot × 折れ線グラフを連動させた可視化ダッシュボード

0
Last updated at Posted at 2026-08-04

データ分析では、1つのグラフだけで全ての情報を表現しようとすると、どうしても情報量が増えすぎてしまいます。

例えば、

・年間全体の傾向を見たい
・季節ごとの分布を比較したい
・日ごとの推移を確認したい

これらはそれぞれ適したグラフが異なります。

そこで今回は、

3D Surface・Violin Plot・折れ線グラフを同期させたインタラクティブなダッシュボードを作成します。

それぞれのグラフが異なる役割を持つことで、データを「俯瞰→比較→詳細確認」という流れで自然に探索できます。

今回は、UCI Machine Learning RepositoryのBike Sharing Dataset (※1) を使います。

・日付×時刻×レンタル台数を表す3D Surface
・選択時刻における季節別のViolin Plot
・選択時刻における日別レンタル台数の折れ線グラフ

画面下部のスライダーで時刻を変更すると、3D Surface上の断面線、Violin Plot、折れ線グラフが同時に更新されます。

sample20260803_4.png

sample20260803_2.png

このダッシュボードでは、スライダーを動かすだけで次の3つが同時に更新されます。

  • 3D Surface上の断面線
  • 季節別Violin Plot
  • 日別レンタル台数の折れ線グラフ

つまり、同じ時刻について

「年間全体ではどこに位置するのか」

「季節ごとの分布はどうか」

「日ごとの推移はどうか」

を一度に確認できます。

今回作成するダッシュボードは、大きく次の流れで実装します。

  1. データの前処理
  2. Surface用データ作成
  3. 各グラフを生成する関数を作成
  4. Plotly Figureへ配置
  5. FrameとSliderを設定
#細かい流れはこちら
① データ取得・前処理
⬇︎
② 3D Surface用データ
⬇︎
③ 表示設定
⬇︎
④ Violin Plot生成関数
⬇︎
⑤ 折れ線グラフ生成関数
⬇︎
⑥ 指定時刻の動的Trace生成
⬇︎
⑦ サブプロット作成
⬇︎
⑧ 3D Surface作成
⬇︎
⑨ 初期表示
⬇︎
⑩ アニメーションFrame作成
⬇︎
⑪ スライダー作成
⬇︎
⑫ 全体レイアウト設定
⬇︎
⑬ 軸設定
⬇︎
⑭ サブタイトル位置調整
⬇︎
⑮ 表示

① データ取得・前処理

UCI Machine Learning RepositoryからBike Sharing Datasetを取得します。
可視化に必要な日付・季節・時刻・レンタル台数だけを残し、2012年のデータへ絞り込みます。

# ============================================
# 1. データ取得・前処理
# ============================================

bike_sharing = fetch_ucirepo(id=275)

df = pd.concat(
    [
        bike_sharing.data.features,
        bike_sharing.data.targets,
    ],
    axis=1,
)

df = df[
    [
        "dteday",
        "season",
        "hr",
        "cnt",
    ]
].rename(
    columns={
        "dteday": "Date",
        "season": "Season",
        "hr": "Hour",
        "cnt": "Count",
    }
)

df["Date"] = pd.to_datetime(df["Date"])
df["Hour"] = pd.to_numeric(df["Hour"])
df["Count"] = pd.to_numeric(df["Count"])

df["Season"] = df["Season"].map(
    {
        1: "",
        2: "",
        3: "",
        4: "",
    }
)

df = (
    df[df["Date"].dt.year == 2012]
    .sort_values(["Date", "Hour"])
    .reset_index(drop=True)
)

display(df.head())

print(f"データ件数:{len(df):,}")
print(
    f"対象期間:"
    f"{df['Date'].min().date()}"
    f"{df['Date'].max().date()}"
)

② 3D Surface用データ

3D Surfaceへ渡すため、行を日付、列を時刻、値をレンタル台数とするピボットテーブルを作成します。

# ============================================
# 2. 3D Surface用データ
# ============================================

pivot_df = (
    df.pivot_table(
        index="Date",
        columns="Hour",
        values="Count",
        aggfunc="mean",
    )
    .reindex(columns=range(24))
    .interpolate(
        axis=1,
        limit_direction="both",
    )
    .ffill()
    .bfill()
)

date_labels = (
    pivot_df.index
    .strftime("%Y-%m-%d")
    .tolist()
)

hour_values = pivot_df.columns.tolist()

hour_labels = [
    f"{hour:02d}:00"
    for hour in hour_values
]

z_values = pivot_df.values

③ 表示設定

季節の並び順、グラフの色、Y軸範囲、タイトルなど、複数のグラフで共有する設定をまとめます。

# ============================================
# 3. 表示設定
# ============================================

SEASON_ORDER = [
    "",
    "",
    "",
    "",
]

colors = {
    "": "#1f77b4",
    "": "#2ca02c",
    "": "#d62728",
    "": "#ff7f0e",
}

fill_colors = {
    "": "rgba(31,119,180,0.22)",
    "": "rgba(44,160,44,0.22)",
    "": "rgba(214,39,40,0.22)",
    "": "rgba(255,127,14,0.22)",
}

seasons = [
    season
    for season in SEASON_ORDER
    if season in df["Season"].unique()
]

count_min = float(df["Count"].min())
count_max = float(df["Count"].max())
count_margin = (count_max - count_min) * 0.08

count_range = [
    max(0, count_min - count_margin),
    count_max + count_margin,
]

TITLE = (
    "Bike Sharing Dataset:"
    "複合可視化ダッシュボード"
)

今回のダッシュボードでは、それぞれのグラフを関数として切り出しています。

こうすることで、

スライダー操作時に同じ関数を呼び出すだけで各グラフを再生成でき、コードの重複を避けながら保守性も向上します。

Surfaceは年間全体を俯瞰する役割を持っています。

一方で、Surfaceだけでは細かな分布や時系列までは読み取りづらいため、

後からViolin Plotと折れ線グラフで詳細を補完します。

④ Violin Plot生成関数

選択された時刻について、季節ごとのレンタル台数分布をViolin Plotで表します。

spanmode="hard"を指定し、Violinの形状が実データの最小値・最大値より外へ伸びないようにしています。

# ============================================
# 4. Violin Plot生成関数
# ============================================

def create_violin_trace(
    season_df: pd.DataFrame,
    season: str,
) -> go.Violin:
    """指定時刻・季節のレンタル台数分布を作成する。"""

    return go.Violin(
        x=[season] * len(season_df),
        y=season_df["Count"],
        name=season,
        legendgroup=season,
        spanmode="hard",
        width=0.72,
        side="both",
        box_visible=True,
        meanline_visible=True,
        points="all",
        jitter=0.18,
        pointpos=-1.15,
        fillcolor=fill_colors[season],
        line_color=colors[season],
        marker=dict(
            color=colors[season],
            size=3,
            opacity=0.35,
        ),
        customdata=(
            season_df["Date"]
            .dt.strftime("%Y-%m-%d")
        ),
        hovertemplate=(
            f"<b>{season}</b><br>"
            "日付:%{customdata}<br>"
            "レンタル台数:%{y:.0f}"
            "<extra></extra>"
        ),
        showlegend=False,
    )

⑤ 折れ線グラフ生成関数

選択時刻におけるレンタル台数を日付順に表示します。

同じ冬でも年初と年末は連続していないため、日付が2日以上空く場所へNoneを挿入して折れ線を切断します。

# ============================================
# 5. 折れ線グラフ生成関数
# ============================================

def create_line_trace(
    season_df: pd.DataFrame,
    season: str,
) -> go.Scatter:
    """
    指定時刻・季節の日別レンタル台数を作成する。

    日付が連続していない箇所にはNoneを挿入し、
    離れた期間同士が線で結ばれないようにする。
    """

    season_df = (
        season_df
        .sort_values("Date")
        .reset_index(drop=True)
    )

    x_values = []
    y_values = []
    customdata = []

    previous_date = None

    for row in season_df.itertuples():

        if (
            previous_date is not None
            and (row.Date - previous_date).days > 1
        ):
            x_values.append(None)
            y_values.append(None)
            customdata.append(None)

        date_label = row.Date.strftime("%Y-%m-%d")

        x_values.append(date_label)
        y_values.append(row.Count)
        customdata.append(date_label)

        previous_date = row.Date

    return go.Scatter(
        x=x_values,
        y=y_values,
        customdata=customdata,
        mode="lines+markers",
        name=season,
        legendgroup=season,
        line=dict(
            color=colors[season],
            width=1.8,
        ),
        marker=dict(
            color=colors[season],
            size=4,
        ),
        hovertemplate=(
            f"<b>{season}</b><br>"
            "日付:%{customdata}<br>"
            "レンタル台数:%{y:.0f}"
            "<extra></extra>"
        ),
        connectgaps=False,
        showlegend=True,
    )

⑥ 指定時刻の動的Trace生成

PlotlyではFrameを利用することで、Figure全体を書き直すことなく表示内容だけを高速に切り替えられます。

今回は各時刻をFrameとして保持し、スライダーから任意のFrameへ切り替える構成にしています。
スライダーで選択された時刻について、次の3種類のTraceをまとめて作成します。

3D Surface上の断面線
季節別Violin Plot
季節別折れ線グラフ

# ============================================
# 6. 指定時刻の動的Trace生成
# ============================================

def create_dynamic_traces(
    hour_index: int,
) -> list:
    """指定時刻の断面線・Violin・折れ線を作成する。"""

    selected_hour = hour_values[hour_index]
    selected_hour_label = hour_labels[hour_index]

    hour_df = df[
        df["Hour"] == selected_hour
    ]

    season_dfs = {
        season: hour_df[
            hour_df["Season"] == season
        ]
        for season in seasons
    }

    cross_section = go.Scatter3d(
        x=[
            selected_hour_label
        ] * len(date_labels),
        y=date_labels,
        z=z_values[:, hour_index],
        mode="lines",
        line=dict(
            color="red",
            width=7,
        ),
        name="選択断面",
        hovertemplate=(
            "時刻:%{x}<br>"
            "日付:%{y}<br>"
            "レンタル台数:%{z:.0f}"
            "<extra></extra>"
        ),
        showlegend=False,
    )

    violin_traces = [
        create_violin_trace(
            season_dfs[season],
            season,
        )
        for season in seasons
    ]

    line_traces = [
        create_line_trace(
            season_dfs[season],
            season,
        )
        for season in seasons
    ]

    return [
        cross_section,
        *violin_traces,
        *line_traces,
    ]

⑦ サブプロット作成

上段に3D Surface、左下にViolin Plot、右下に折れ線グラフを配置します。

colspan=2を指定することで、3D Surfaceが上段全体を使用します。

# ============================================
# 7. サブプロット作成
# ============================================

fig = make_subplots(
    rows=2,
    cols=2,
    row_heights=[
        0.62,
        0.38,
    ],
    column_widths=[
        0.32,
        0.68,
    ],
    specs=[
        [
            {
                "type": "scene",
                "colspan": 2,
            },
            None,
        ],
        [
            {
                "type": "xy",
            },
            {
                "type": "xy",
            },
        ],
    ],
    horizontal_spacing=0.06,
    vertical_spacing=0.07,
    subplot_titles=[
        "",
        "季節別の分布",
        "日付別の推移",
    ],
)

⑧ 3D Surface

時刻・日付・レンタル台数の3軸からなるSurfaceを作成します。

全体の傾向を俯瞰でき、時間帯や季節によるレンタル台数の変化を立体的に確認できます。

# ============================================
# 8. 3D Surface
# ============================================

fig.add_trace(
    go.Surface(
        x=hour_labels,
        y=date_labels,
        z=z_values,
        colorscale="Viridis",
        cmin=count_range[0],
        cmax=count_range[1],
        colorbar=dict(
            title="レンタル台数",
            len=0.46,
            thickness=28,
            x=0.91,
            y=0.77,
        ),
        hovertemplate=(
            "時刻:%{x}<br>"
            "日付:%{y}<br>"
            "レンタル台数:%{z:.0f}"
            "<extra></extra>"
        ),
        showscale=True,
    ),
    row=1,
    col=1,
)

⑨ 初期表示

PlotlyのFrameは最初から自動表示されないため、0時のTraceを初期データとしてFigureへ追加します。

# ============================================
# 9. 初期表示
# ============================================

initial_hour_index = 0

initial_traces = create_dynamic_traces(
    initial_hour_index
)

season_count = len(seasons)

# 3D断面線
fig.add_trace(
    initial_traces[0],
    row=1,
    col=1,
)

# Violin Plot
for trace in initial_traces[
    1:1 + season_count
]:
    fig.add_trace(
        trace,
        row=2,
        col=1,
    )

# 折れ線グラフ
for trace in initial_traces[
    1 + season_count:
]:
    fig.add_trace(
        trace,
        row=2,
        col=2,
    )

# Surface以外をアニメーションで更新
dynamic_trace_indices = list(
    range(
        1,
        len(fig.data),
    )
)

⑩ アニメーションFrame

0時から23時まで、それぞれの時刻に対応する断面線・Violin Plot・折れ線グラフをFrameとして作成します。

# ============================================
# 10. アニメーションFrame
# ============================================

fig.frames = [
    go.Frame(
        name=hour_label,
        data=create_dynamic_traces(
            hour_index
        ),
        traces=dynamic_trace_indices,
        layout=go.Layout(
            title=dict(
                text=(
                    f"{TITLE}<br>"
                    f"<sup>"
                    f"選択時刻:{hour_label}"
                    f"</sup>"
                )
            )
        ),
    )
    for hour_index, hour_label
    in enumerate(hour_labels)
]

⑪ スライダー

各Frameへ移動するためのスライダーを作成します。

スライダーを動かすと、3D断面線・Violin Plot・折れ線グラフが同時に更新されます。

# ============================================
# 11. スライダー
# ============================================

slider_steps = [
    {
        "method": "animate",
        "label": hour_label,
        "args": [
            [
                hour_label
            ],
            {
                "mode": "immediate",
                "frame": {
                    "duration": 0,
                    "redraw": True,
                },
                "transition": {
                    "duration": 0,
                },
            },
        ],
    }
    for hour_label in hour_labels
]

⑫ 全体レイアウト

タイトル、スライダー、3Dカメラ、凡例、余白など、ダッシュボード全体の見た目を設定します。

凡例は右下の折れ線グラフ内に配置しています。

# ============================================
# 12. 全体レイアウト
# ============================================

fig.update_layout(
    template="plotly_white",
    height=900,
    width=1200,

    title=dict(
        text=(
            f"{TITLE}<br>"
            "<sup>"
            "選択時刻:00:00"
            "</sup>"
        ),
        x=0.5,
        xanchor="center",
        y=0.98,
        yanchor="top",
    ),

    sliders=[
        {
            "active": initial_hour_index,
            "currentvalue": {
                "visible": False,
            },
            "pad": {
                "t": 48,
                "b": 5,
            },
            "steps": slider_steps,
        }
    ],

    scene=dict(
        domain=dict(
            x=[
                0.03,
                0.90,
            ],
            y=[
                0.46,
                0.96,
            ],
        ),

        xaxis=dict(
            title="時刻",
            type="category",
            nticks=9,
            showgrid=True,
            zeroline=False,
        ),

        yaxis=dict(
            title="日付",
            type="category",
            nticks=8,
            showgrid=True,
            zeroline=False,
        ),

        zaxis=dict(
            title="レンタル台数",
            range=count_range,
            autorange=False,
            nticks=6,
            zeroline=False,
        ),

        aspectratio=dict(
            x=2.25,
            y=1.25,
            z=0.68,
        ),

        camera=dict(
            eye=dict(
                x=1.65,
                y=-1.65,
                z=1.00,
            )
        ),
    ),

    legend=dict(
        orientation="v",
        x=0.985,
        y=0.405,
        xanchor="right",
        yanchor="top",
        bgcolor="rgba(255,255,255,0.88)",
        bordercolor="rgba(150,150,150,0.45)",
        borderwidth=1,
        font=dict(
            size=11,
        ),
    ),

    margin=dict(
        l=55,
        r=45,
        t=85,
        b=85,
    ),

    uirevision="bike-dashboard",
)

⑬ 軸設定

Violin Plotと折れ線グラフの軸タイトル、カテゴリ順、表示範囲、目盛り数などを設定します。

# ============================================
# 13. 軸設定
# ============================================

# Violin PlotのX軸
fig.update_xaxes(
    title_text="季節",
    categoryorder="array",
    categoryarray=seasons,
    showgrid=False,
    row=2,
    col=1,
)

# 折れ線グラフのX軸
fig.update_xaxes(
    title_text="日付",
    type="category",
    categoryorder="array",
    categoryarray=date_labels,
    tickangle=-45,
    nticks=12,
    row=2,
    col=2,
)

# 下段2グラフ共通のY軸
for column in (
    1,
    2,
):
    fig.update_yaxes(
        title_text="レンタル台数",
        range=count_range,
        autorange=False,
        rangemode="tozero",
        nticks=6,
        row=2,
        col=column,
    )

⑭ サブタイトル位置調整

make_subplots()によって自動生成されたサブタイトルのフォントサイズと表示位置を調整します。

# ============================================
# 14. サブタイトル位置調整
# ============================================

for annotation in fig.layout.annotations:
    annotation.font.size = 16

    if annotation.text in {
        "季節別の分布",
        "日付別の推移",
    }:
        annotation.y = 0.405

⑮ 表示

最後にfig.show()を実行し、作成したインタラクティブダッシュボードを表示します。

# ============================================
# 15. 表示
# ============================================

fig.show()

今回の可視化では、

「1枚のグラフへ全情報を詰め込む」のではなく、

役割の異なる複数の可視化を同期させることで、分析を段階的に進められるよう設計しました。

分析では

① 全体を見る

② 気になる時間帯を見つける

③ 分布を確認する

④ 時系列を追う

という流れでデータを見ることが多くあります。

今回のダッシュボードは、この分析プロセスをそのままインタラクティブな可視化として実現した例になります。

そこからスライダーで任意の時刻を選択し、同じ時刻について、

➡︎季節別にどのような分布を持っているか
➡︎日付ごとにどのように推移しているか

を下段の2つのグラフで確認できます。

単一のグラフへ情報を詰め込むのではなく、俯瞰・分布・時系列という異なる視点を同期させることで、データを段階的に探索できる構成にしています。

今回の構成はBike Sharing Dataset以外にも応用できます。

例えば、

・店舗の売上分析
・工場IoTセンサーデータ
・アクセスログ分析
・電力消費量の分析

など、時間軸を持つデータであれば幅広く利用できます。

分析対象が変わっても、「俯瞰→分布→時系列」という考え方は共通です。

参考文献

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?