包包baobaolin.com
date
entry
002
topic
method
rev

分頁上限做出來的假趨勢

我在抓取腳本裡設了一個保險用的上限。它沒有出錯、沒有報警,只是安靜地讓資料長出一個不存在的趨勢——而且方向跟事實相反。

我想知道某個題目最近有多少研究,於是寫了一段分頁抓取,把 arXiv 上幾個分類、三個月內的論文抓下來。 為了避免程式邏輯出錯時無限翻頁,我加了一個上限:

MAXPAGE = 60   # 60 * 100 = 6000 筆/分類,跑飛時的保險

for page in range(MAXPAGE):
    start = page * 100
    url = (f"...search_query=cat:{cat}+AND+submittedDate:[{A}+TO+{B}]"
           f"&start={start}&max_results=100"
           f"&sortBy=submittedDate&sortOrder=descending")
    ...

這是很常見的防呆。它的用意是「正常情況下不會碰到」,所以我沒有為它寫任何告警。

資料看起來像一個發現

抓完之後,按月統計是這樣:

2026-05:  4,902
2026-06:  7,671
2026-07:  8,648

三個月成長將近一倍。這個數字很誘人,因為它剛好可以支持我原本想講的故事——這個領域正在快速升溫。 我差一點就把它寫進結論。

會讓你出事的資料,通常不是明顯錯誤的那種,是剛好符合你預期的那種。

停下來的原因很單純:arXiv 的投稿量不會在三個月內變成三倍。這個成長幅度不像真的。

問資料來源,不要問資料

要驗證抽樣有沒有偏差,最直接的方法是問一個不依賴自己抓取結果的問題。 arXiv 的 API 回應裡有 <opensearch:totalResults>,可以只要一筆結果、單純問「這個條件總共有幾篇」:

url = (f"...search_query=cat:{cat}"
       f"+AND+submittedDate:[{a}0000+TO+{b}2359]"
       f"&start=0&max_results=1")
total = int(re.search(r'<opensearch:totalResults[^>]*>(\d+)<', s).group(1))

拿三個星期去問,結果是這樣:

分類05-01~05-0706-01~06-0707-24~07-30
cs.AI1,3311,444944
cs.LG1,3501,325835
cs.CL536731376

五月的第一週比七月的最後一週還多。 真實方向跟我的資料完全相反。那個「成長」百分之百是我自己造出來的。

為什麼截斷會落在舊的那一端

關鍵在 sortOrder=descending。結果從最新的開始給,所以抓到第 6,000 筆停下時, 手上握的是最近的 6,000 筆,被丟掉的是時間軸較舊的那一端。

三個大分類在三個月內各有一到兩萬篇,全都撞到上限;其他六個分類量小,完整抓完了。 於是最終資料呈現的樣子是:越靠近現在,覆蓋越完整;越往前,缺得越多。 把它按月畫出來,就長得跟一條漂亮的成長曲線一模一樣。

這個錯誤有兩個性質讓它特別難抓:

  • 它不會報錯。迴圈正常結束,沒有例外,沒有重試,日誌乾淨。
  • 它產生的是合理的形狀。如果截斷是隨機的,資料會變得雜亂而讓人起疑;但依時間排序的截斷產生的是單調趨勢,而單調趨勢看起來像知識。

修法:讓抓取自己證明自己完整

真正的修法不是把上限調大——調大只是把問題推遠,下次資料量成長時它會再回來,而且一樣不出聲。 修法是把「我抓完了」從假設變成可驗證的斷言:先問總數,抓完之後比對。

for c in CATS:
    exp = total(c)                    # 先問官方總數
    got = 0
    for start in range(0, exp + 100, 100):   # 迴圈邊界改由 exp 決定
        ...
        got += n
    verify[c] = {'official_total': exp, 'fetched': got}
    print(f"{c} 官方 {exp} / 實抓 {got} {'✓' if got >= exp else '✗'}")

兩個差別。第一,迴圈的終止條件來自資料來源而不是我拍腦袋的常數。 第二,每一類都留下一行「官方說有幾筆、我拿到幾筆」,而且這行會跟資料一起存檔—— 日後任何人(包括我自己)要質疑這份資料完不完整,有東西可以看。

重跑之後九個分類全部相符。這時候的數字才拿得出去。

順帶一提:cat: 不是你以為的那個 cat

同一次還踩到另一個坑。search_query=cat:cs.AI 匹配的是 任何一個分類標籤是 cs.AI 的論文,包含交叉列名,不是「主分類為 cs.AI」。

所以九個分類各自查詢再去重,跟「這九類總共有幾篇不重複的論文」是兩件事, 而且單一分類的 totalResults 也不等於該分類的主分類論文數。 我是在核對數字時發現去重後的量遠小於各類相加,才回頭確認這件事。

如果只記得一件事

你自己設的保險上限,在輸出裡是隱形的。 程式不會告訴你它碰到了上限——除非你叫它說。而排序過的截斷, 產生的不是雜訊,是趨勢。

所以看到單調的成長曲線時,先懷疑自己的取樣,再懷疑世界。 尤其是當那條曲線剛好支持你原本想講的話的時候。

修訂紀錄

  1. 首次發布