↓快轉到主要內容
  1. 教學文章/

Plotly 時間軸實戰:缺測斷線、日期排序與 Rangebreaks

·7 分鐘· loading · loading · ·
Python Plotly Time-Series Missing-Data Rangebreaks Data-Visualization
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 151: 本文

一. 前言:沒有資料,為什麼圖上還有一條線?
#

嗨,我是拍拍君。 今天先問一個很容易答錯的問題: 如果星期二整天沒有回報資料,Plotly 折線圖會自動斷開嗎?

不一定。只要那一天連資料列都不存在,線通常就直接接過去。

更麻煩的是,把日期改成字串類別,畫面還會變得很整齊。 星期五到星期一,和星期一到星期二,看起來竟然一樣遠。 乾淨是乾淨了,時間卻悄悄被改寫。

這篇接續 Plotly 基礎篇,不重做互動圖表大巡禮。 我們只處理一件事:線段和水平距離,到底對讀者承諾了什麼?

範例使用虛構的拍拍商店日報,不涉及真實營運資料。 最後會得到保留日曆與壓縮休息日的兩版圖,以及可自動檢查的契約。

二. 安裝:用小資料把語意看清楚
#

建立專案:

uv init plotly-time-axis
cd plotly-time-axis
uv add plotly pandas pytest

以下第三到第八節的 Python 程式,依序放進同一個 time_axis.py。 第九節另外存成 test_time_axis.py。 這裡使用 go.Scatter,明確選擇 SVG trace,避免 Rangebreaks 與 WebGL trace 的相容性問題。

本文實測環境為 Python 3.12.12、Plotly 7.1.0、pandas 3.1.0、pytest 9.2.0。 套件版本會前進,請保留專案的 uv.lock,升級後重跑驗證。

我們會刻意保留四種狀況:

日期 狀況 正確解讀
10/20 數值為 0 有觀測,而且確實是零
10/21 有列,數值為空 已回報缺測
10/27 整列不存在 預期回報,但沒有收到
10/23、週末 商店休息 本來就不在觀測日曆內

缺測、零與休息日,是三個不同的故事。 不要讓一個 fillna(0) 把它們全部寫成同一種結局。

三. 日期輸入:先解析,再排序,再拒絕重複
#

Plotly 折線依照輸入順序連接,不會先替你按日期排序。 日期軸只是決定座標位置,不是清理資料的魔法。

from pathlib import Path

import pandas as pd
import plotly.graph_objects as go

raw = pd.DataFrame({
    "date": ["2026-10-28", "2026-10-19", "2026-10-21",
             "2026-10-20", "2026-10-22", "2026-10-26",
             "2026-10-29", "2026-10-30"],
    "value": [150, 100, None, 0, 120, 130, 140, 160],
})


def prepare(source):
    data = source.copy()
    data["date"] = pd.to_datetime(
        data["date"], format="%Y-%m-%d", errors="raise"
    )
    if data["date"].isna().any():
        raise ValueError("日期不可為空")
    if data["date"].duplicated().any():
        raise ValueError("同一天有多列,請先定義聚合或版本規則")
    return data.sort_values("date").reset_index(drop=True)


ordered = prepare(raw)

這個小函式刻意不幫你選「同一天最後一筆」。 因為兩筆可能是不同分店,也可能是同一份日報的修訂版。 直接去重或取平均,可能會丟掉真正有意義的資料。

多店資料應先以店名分組,各組各自驗證日期唯一性,再各畫一條 trace。 不要把兩間店交錯塞進同一條線。

這裡的日期只代表商店的日曆日,不是跨時區瞬間。 若來源是帶時區 timestamp,請先完成業務日期轉換;時區細節可看 zoneinfo 篇。

四. 缺列和空值:connectgaps=False 不是缺測偵測器
#

connectgaps=False 能保留輸入陣列裡的空值缺口。 但 10/27 根本沒出現在 ordered 裡,Plotly 不知道那天「應該」有一筆。 即使日期間隔拉長,它也不會替你判定這叫漏報。

因此要先拿出外部規格:這間店在哪些日子應該回報?

closed = pd.DatetimeIndex(["2026-10-23"])
expected = pd.bdate_range("2026-10-19", "2026-10-30")
expected = expected.difference(closed)


def complete_calendar(data, calendar):
    if not data["date"].isin(calendar).all():
        raise ValueError("資料包含觀測日曆以外的日期")
    indexed = data.set_index("date")
    result = indexed.reindex(calendar).rename_axis("date")
    result["status"] = "observed"
    result.loc[result["value"].isna(), "status"] = "reported_missing"
    absent = ~result.index.isin(indexed.index)
    result.loc[absent, "status"] = "absent_row"
    return result.reset_index()


daily = complete_calendar(ordered, expected)
print(daily[["date", "value", "status"]].to_string(index=False))

bdate_range 在這裡只提供週一至週五的骨架。 它不是交易所日曆,也不會自動知道國定假日、補班或臨時店休。 10/23 是我們明確給定的虛構店休日。

執行後有 9 列;其中 10/21 是 reported_missing,10/27 是 absent_row。 兩者在畫線時都要斷開,但在資料品質追查時,責任來源不同。

不要從最早和最晚兩筆資料反推整份報表範圍。 如果開頭或結尾本身漏報,那種作法會把漏報日排除在檢查之外。 本例直接由報表規格固定起訖日。

五. 保留日曆軸:讓缺測真的出現
#

把時間骨架交給 trace,並明確把缺測值轉成 None:

def make_figure(data, title):
    values = [None if pd.isna(v) else float(v) for v in data["value"]]
    fig = go.Figure(go.Scatter(
        x=data["date"].dt.strftime("%Y-%m-%d").tolist(),
        y=values,
        mode="lines+markers",
        connectgaps=False,
        name="拍拍商店",
        line=dict(color="#31688e", width=3),
        marker=dict(size=9),
    ))
    fig.update_xaxes(type="date", tickformat="%m/%d", title="日曆日期")
    fig.update_yaxes(title="日報值(示範單位)")
    fig.update_layout(
        title=title, template="plotly_white", height=440,
        margin=dict(t=85, b=70), showlegend=False,
    )
    return fig


calendar_fig = make_figure(daily, "完整日曆距離;兩個缺測日不連線")
for day in daily.loc[daily["status"] != "observed", "date"]:
    calendar_fig.add_vrect(
        x0=(day - pd.Timedelta(hours=12)).strftime("%Y-%m-%d %H:%M"),
        x1=(day + pd.Timedelta(hours=12)).strftime("%Y-%m-%d %H:%M"),
        fillcolor="#efb366", opacity=0.2, line_width=0,
    )

完整日期軸:10/21 與 10/27 淡橘色缺測帶,10/20 保留零值

淡橘色帶代表缺測日,線則在 10/21 與 10/27 斷開。 有空值的點本身沒有 marker,不能只期待讀者 hover 到一個不存在的點。 色帶加上文字說明,才能讓缺口有可見的解釋。

注意:10/22 到 10/26 仍然會連線。 中間休息日沒有被補成缺測,因為商店本來就不需要回報。 這條線只是在連接相鄰的有效觀測,不代表中間每天都有量測。

如果領域規範要求「超過兩天就不連線」,應另外按間隔切成多條 trace,或插入斷點。 那是新的繪圖政策,不是 connectgaps 會自動做到的事。

六. 日期軸與類別軸:一樣的標籤,不一樣的尺
#

來做一個反例:只保留有效觀測,再把日期改成類別軸。

observed = ordered.dropna(subset=["value"])
date_fig = make_figure(observed, "只有有效觀測:保留日曆距離,但會跨過缺測")
category_fig = go.Figure(date_fig)
category_fig.update_xaxes(
    type="category", categoryorder="array",
    categoryarray=observed["date"].dt.strftime("%Y-%m-%d").tolist(),
    title="觀測順序(每筆等距,不是經過時間)",
)
category_fig.update_layout(title="反例:類別軸把每筆觀測排成等距")

在日期軸上,10/22 到 10/26 的距離,是 10/19 到 10/20 的四倍。 在類別軸上,它們都只差一格。

因此不能拿類別軸線段的視覺斜率,直接比較每日變化速度。 類別軸並非不能用;如果問題是「第幾次觀測」,它可能很合適。 但請把軸名稱寫成觀測順序,不要假裝那是一把連續時間尺。

同樣重要的是:date_fig 雖然保留時間距離,仍會跨過被丟掉的缺測列。 選對軸型別,和保留缺測斷點,是兩個獨立任務。

七. Rangebreaks:休息日可以隱藏,漏報日不可以偷藏
#

另一種需求是想專心看營業日,不讓週末佔據太多空間。 這時可以用 rangebreaks,但必須揭露水平距離已被壓縮。

compressed_fig = go.Figure(calendar_fig)
compressed_fig.update_xaxes(
    rangebreaks=[
        dict(bounds=["sat", "mon"]),
        dict(values=["2026-10-23"], dvalue=24 * 60 * 60 * 1000),
    ],
    title="營業日日期(已隱藏週末與 10/23 店休)",
)
compressed_fig.update_layout(
    title="壓縮休息日;10/21、10/27 缺測仍保留",
)

壓縮日期軸:週末與指定店休日隱藏,但兩處缺測仍斷線

bounds=["sat", "mon"] 隱藏週六到週一之前的時段。 values 指定額外的起點;dvalue 的單位是毫秒,這裡隱藏一天。 它只改變顯示,不會補資料、改變原始日期,或把空值修好。

請特別避免這種寫法:找出所有沒數字的日期,通通塞進 values。 那會把真正的漏報也藏掉,讀者再也看不出品質問題。

本例的 closed 來自營業規格,而不是從資料的空值推測。 10/21 和 10/27 不在隱藏清單裡,所以斷線仍然可見。

官方文件也提醒,Rangebreaks 不支援 scattergl。 本例直接使用 go.Scatter;若換成會自動選 WebGL 的 Express 圖,需檢查 trace 型別,必要時指定 render_mode="svg"。

八. 匯出:把對照版一起交出去
#

不要只交一張「看起來更順」的壓縮版。 保留原始日曆版,讀者才知道那些空間是什麼。

figures = {
    "calendar": calendar_fig,
    "date-observed-only": date_fig,
    "category-observed-only": category_fig,
    "compressed": compressed_fig,
}

if __name__ == "__main__":
    out = Path("reports")
    out.mkdir(exist_ok=True)
    for name, fig in figures.items():
        fig.write_html(
            out / f"{name}.html", include_plotlyjs=True,
            full_html=True, div_id=f"time-axis-{name}",
        )
    daily.to_csv(out / "daily-audit.csv", index=False)

執行 uv run python time_axis.py,會產生四份可離線開啟的 HTML。 daily-audit.csv 則保留缺測分類,方便對照線為什麼中斷。 不要只檢查輸出檔存在;打開 HTML,看看實際線段、日期與陰影。

九. 自動檢查:先驗證契約,再看漂亮程度
#

測試另外放進 test_time_axis.py:

import pandas as pd
import pytest

from time_axis import (
    raw, prepare, daily, calendar_fig, category_fig, compressed_fig,
)


def test_sort_and_duplicate_policy():
    assert prepare(raw)["date"].is_monotonic_increasing
    with pytest.raises(ValueError, match="同一天"):
        prepare(pd.concat([raw, raw.iloc[[0]]]))


def test_missing_is_not_zero():
    rows = daily.set_index("date")
    assert rows.loc["2026-10-20", "value"] == 0
    assert rows.loc["2026-10-21", "status"] == "reported_missing"
    assert rows.loc["2026-10-27", "status"] == "absent_row"
    assert len(daily) == 9
    assert daily["value"].isna().sum() == 2


def test_figure_contract():
    trace = calendar_fig.data[0]
    assert trace.type == "scatter"
    assert trace.connectgaps is False
    assert list(trace.y).count(None) == 2
    assert calendar_fig.layout.xaxis.type == "date"
    assert category_fig.layout.xaxis.type == "category"
    breaks = compressed_fig.layout.xaxis.rangebreaks
    assert list(breaks[0].bounds) == ["sat", "mon"]
    assert list(breaks[1].values) == ["2026-10-23"]
    assert list(compressed_fig.data[0].y) == list(trace.y)

執行 uv run pytest -q。 這些測試確認資料和 Figure 設定,不能代替瀏覽器的實際渲染檢查。 升級套件後,也要重新打開 HTML,檢查缺口是否仍在、壓縮規則是否符合標題。

結語:時間軸也是一份資料契約
#

拍拍君最想提醒的不是某個參數,而是三條邊界:

  • 空值能形成斷點,缺列得先有觀測日曆才認得出來。
  • 日期軸保留經過時間,類別軸保留的是項目順序。
  • Rangebreaks 可以壓縮已知休息日,不能拿來掩蓋漏報。

圖表畫得順,不等於資料真的連續。 先想清楚線段代表什麼,再決定要不要把它接起來。 拍拍君下次見。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 151: 本文

相關文章

Polars Rolling / Dynamic Windows:時間窗聚合、邊界語意與 QA
·6 分鐘· loading · loading
Python Polars Time-Series Rolling-Window Dynamic-Window Data-Engineering Testing
Streamlit + PyDeck 地理資料 Dashboard:圖層、篩選與互動地圖
·7 分鐘· loading · loading
Python Streamlit PyDeck GeoJSON Geospatial Dashboard Data-Visualization
Pandas 資料清理實戰:缺失值、型別、重複資料與驗證
·7 分鐘· loading · loading
Python Pandas Data-Cleaning Missing-Data Validation ETL
Textual DataTable 身分契約:Row Key、排序篩選與選取還原
·7 分鐘· loading · loading
Python Textual DataTable RowKey TUI Testing
Polars Enum / Categorical:分類詞彙、未知值與跨批次一致性
·7 分鐘· loading · loading
Python Polars Enum Categorical Data-Contracts Schema
uv Tool 生命週期:uvx、持久安裝、版本固定與 PATH 診斷
·8 分鐘· loading · loading
Python Uv Uvx Cli PATH Developer-Tools