2019年1月27日 星期日

拿股票三維資料學習 pandas 的 multiindex

股票三維資料 最近認真用 python 的 pandas 函式庫讀取股票每日收盤資料, 結論是: 嗯, 還是用陽春 python 處理比較簡單快速 -- 執行的時間跟程式除錯的時間都比較省。 本篇是跟 pandas 的分手宣言。 閱讀本文之前, 請先按照 我上個月的 pandas 開箱文 操作一遍。 (好短的戀情啊~~)

請下載本文的主角, 我的範例程式 sana.py。 也請用 pip3 安裝它用到的一堆套件, 例如 pandas, numpy, tulipy, cProfile 等等。 另外請按照 sana.py 裡面的 datapath 的設定, 建立一個空目錄 ~/stock/day , 再把 sana-daily-data.tgz 在這裡解壓縮。 這是去年 10 月到目前為止的台股所有個股每日收盤價等等資料。

進入 python3 之後, 執行 exec(open("sana.py").read()) 然後查看 twstocks 變數的值、 查看一下 twstocks['2330'].name, 確認一下程式執行成功。

一、 把兩個維度擠在 index 裡面

股票一日收盤資料是二維的表格, 很適合放在一張試算表裡: 每列是一檔股票; 每行則有代號、 名稱、 成交量、 ... 收盤價等等共八欄。 可是很多天的資料疊起來, 就變成三維的資料。 讀取的時候每個檔案是一天; 分析的時候卻希望按照每檔股票做 「剖面/橫切面」, 得到 「每頁是一檔股票、 每列代表一天」 的很多張試算表。 這正是採用 多層索引 multiindex 最自然的時機。

所謂多層索引, 可以這樣想: 如果我要用陽春的 python 來存放三維的資料, 但是又不准我用多維陣列, 那麼我可能會用一個 dict, 它的每個 key 是一個長度 3 的 tuple, 類似這樣:

twstocks = {}
twstocks[('2330','181001','收盤價')] = 263
twstocks[('2330','181002','收盤價')] = 257.5
...

既然 pandas 的 dataframe 本身就長得像是二維陣列, 我們的 key 就不需要是 3-tuple, 用 2-tuple (股票代號, 日期) 就可以了; 成交量、 ... 收盤價等等欄位這個維度則拿來當作 columns。 以 pandas 的術語來說, 這個 multiindex 有兩個 levels

二、 讀取 csv 檔

先試一下這一句: oneday = pd.read_csv(datapath+'/181001.csv', index_col=0, dtype={'代號':str, '名稱':str}) 意思是: 讀入 2018 年 10 月 1 日那天的 csv 檔, 建立一個 dataframe, 以最左邊那一欄作為 index, 其中欄位名稱 「代號」 跟 「名稱」 這兩欄是字串; 其他欄位預設會被當成是數字。

現在請看 sana.py 裡我自己定義的 read_csv() 函數。 我偶爾會在某一些 csv 檔裡面用 # 放註解列。 呼叫 pandas 的 read_csv() 時, 只要傳 comment='#' 就可以處理這種狀況。 另外, 我也常在產生 csv 檔時多印一些空白字元, 降低壓迫感。 這時必須指定 skipinitialspace=True 要求 pandas 的 read_csv 忽略逗點後面的多餘空格。

我的 read_csv() 可能不只用來讀每日價格, 未來還會拿來讀其他各種 csv 檔, 例如某檔股票的每月營收。 為了讓呼叫者可以用欄位名稱 (而不是固定用第 0 欄) 來指定要由哪一欄當 index, 所以在讀檔時先用 index_col=False 指定不要 index, 之後才用 set_index() 依名稱指定 index。

對 index 排序, 是為了後續使用方便。 例如月營收檔的 index 可能是月份, 所以 index 必須先排序, 之後才可以用 df.loc[begin:end] 的語法截取起迄年月之間的所有資料。 注意: set_index() 跟 sort_index() (還有其他很多函數也是) 預設並不會更動原本的 dataframe, 而是會傳回一個新的 dataframe。 指定 inplace=True 叫它不要囉嗦原地解決就好, 這可以結省一些時間。

有時在某些 csv 檔裡, 該是數值的欄位, 可能出現文字 (例如某些冷門股當天沒有任何交易)。 為了要讓後續的處理順暢, 應該把所有的文字都變成 NaN。 根據 這個問答, 可以用 df[colnames] = df[colnames].apply(pd.to_numeric, errors='coerce') 來達成。

三、 按照每檔股票切片資料, 慢版

再來很簡單啊, 每讀入一天的資料, 就把它切成一橫條一橫條, 每一橫條是一檔股票, 分別疊到各檔股票各自的 (按日堆疊) 表格去。 自己手動做一次就知道我的意思:

s2330 = stock('2330', {'名稱':'台積電', '收盤價':222.5})
oneday = read_csv(datapath+'/181001.csv')
s2330.byday = pd.DataFrame(columns=oneday.columns)
s2330.byday = s2330.byday.append(oneday.loc['2330'])
oneday = read_csv(datapath+'/181002.csv')
s2330.byday = s2330.byday.append(oneday.loc['2330'])

先在 s2330 這個股票物件裡建一個 .byday 屬性, 按照 oneday 的欄位建一個空的 dataframe。 然後把 2018/10/01 跟 2018/10/02 兩天的台積電數據填進去。 其中 oneday.loc['2330'] 也可寫成 oneday['2330':'2330']。 後面這種語法一定要有起迄 index, 通常較適用於 index 已排序的情況, 例如一段年齡層區間或 (某種排名) 第幾名到第幾名的資料。 一個好處是可以截取一段區間 (好幾橫列); 另一個好處是當你不確定這個 (key) 值是否出現在 index 之中時, 用後面這種語法可能會傳回一列或零列的資料, 程式都可以正常運作而不會出現 exception。 下面的實驗馬上會看到。

現在請看 sana.py 裡的 slow_read_daily() 函數。 把上面那段手動的指令改一下, 就變成了函數中雙層迴圈的最內層。 如果為了想要省略那句 if not sid in table: continue 因而把內層迴圈的 for sid in all_sids: 改成 for sid in daily_data.index:, 那麼執行時會出現 KeyError , 而且還看不出是哪一列出錯的! 這時若再把 daily_data.loc[sid] 改成 daily_data[sid:sid] 就不會有這個錯誤了。

在 slow_read_daily() 的最後面, 一列一列剪貼完之後, 再選取 「日期」 欄作為每一檔個股的 index, 並且把 「名稱」 欄刪掉。

我的主程式呼叫的其實是快版的 fast_read_daily() (下詳), 因為慢版的... 慢到令人受不了: 請把 cProfile.runctx(...) 那一句裡的 fast_read_daily() 改成 slow_read_daily(), 重新再 exec(open("sana.py").read()) 一次。 超級久, 有沒有? 我的電腦要六七分鐘! 「用 cProfile 測量效能瓶頸」 這是從 gtwang 大大的文章 學來的, 再按照 這個問答 小修改過。

slow_read_csv 效能分析 剛剛已在 datapath 目錄底下建立了一個 techan.prof 效能分析二進位檔。 回到命令列下, 大致照著 gtwang 的文章做, 先在 bash 底下用 sudo apt install graphvizpip3 install gprof2dot 安裝所需套件, 再用 cd ~/stock/day ; python3 -m gprof2dot -f pstats techan.prof | dot -T svg -o slow.svg 產生 slow.svg 最後從瀏覽器裡按 ctrl-o 打開壯觀的時間分析圖。 暖色系 (紅、黃) 表示耗時較多的函數呼叫。 可以看到呼叫 (data)frame 的 append 就像是走爛泥巴路一樣,耗掉了最多的時間。 (我猜主要是因為 每 append 一次整個 dataframe 就必須複製一次。)

雖然這個版本超級慢, 但讓我學了不少東西, 很值得記錄下來。

四、 快版: 很多片起司同時切條

slow_read_csv 效能分析 現在請把 slow_read_daily() 改回 fast_read_daily(), 重跑一次, 在我的電腦上, 只花了不到三秒的時間! 回到 bash, 重新產生另一張圖 fast.svg , 可以看到耗時第一名的函數變成了 xs() (下詳); 原先在 slow.svg 裡面 (相對來說, 小到) 被忽略掉的 read_csv() 現在變得明顯可見, 位居第二名。 怎麼差那麼多?

想像有很多起司片要切條重新整理。 你會一片一片切條嗎? 當然不會! 你會把 N 片起司疊整齊 (抱歉, 找不到更整齊的圖片) 然後每一刀切下去就同時切出 N 條, 這樣不是快多了嗎?

起司片堆成一疊 請看 fast_read_daily() 函數。 讀了 這個討論串 之後 (尤其是 "performant" 那一段), 發現大家都偏好使用 concat, 而且一口氣做, 效率會比較高。 所以我們先讀入所有的資料, 不加工直接放進一個陽春 dict 裡。 (或是陽春陣列也可以) 然後 只呼叫一次 concat 把陽春 dict 裡所有的 datafranes 通通串起來。 這一步就相當於把所有的起司片疊整齊。 因為原來資料的 index 是 「代號」; 希望產生的新資料則將以 「日期」 作為 index, 所以呼叫 concat() 時, 傳入 names=['日期','代號'], 指定以這兩欄 (兩個維度) 合併作為 multiindex 的兩個 levels。 「使用 concat 時順便建立 mutliindex」 從這篇自問自答學到最多: pandas.concat: The Missing Manual

把沒用的 「名稱」 欄位丟掉之後, 最後的重點就是呼叫 dataframe 的 xs() 函數 -- 它的名字是 cross-section (橫切面) 的簡寫 -- 這一步就相當於一刀切下去。 傳入 level='代號' 意指刀子切的方向是沿著股票代號這個維度, 每檔股票的所有資料變成一個 dataframe。 當然, 這些新的 datafranes 的 index 就只有單純的一個維度 (一個 level), 而不再是複雜的 multi-index。 不過呢, 有些個股沒有每日交易資訊, 所以遇到這種切片時, pandas 會跳出 KeyError 的 exception。 Pandas xs 函數的主要開發者建議此時應該 用 reindex 把原始資料的所有 index 組合預先擴張完整。 我跟提 issue 的原 po 想法一樣: 如果它可以傳回一個空的 dataframe 不是也很合理而且更加方便嗎? 總之, 最後我的解決方式是: 先檢查這檔股票到底有沒有每日交易資訊, 如果有, 就做橫切面; 如果沒有, 就比照第一檔股票 (1101 台泥, 一定有資料) 的欄位建立一個空的 dataframe。

五、 Tulip 技術指標函式庫

Tulip Indicator 函式庫 提供了數十種 K 線圖技術分析指標函數。 Tulipy 是它的 python 語言轉接頭。 要呼叫 tulipy 的函數之前, 必須先把每日收盤價等等資料補齊 -- 比方說, 會不會有某些檔股票在某些日子裡完全沒出現在 csv 檔當中? 例如最近剛上市或最近剛下市的股票? 或是某幾天暫停交易的股票? 所以在 need_days() 裡面, 我們呼叫 reindex() 函數, 傳進 「(等一下需要用到資料的) 所有日期」, 以便把 dataframe 擴張補齊、 填好填滿。 當然補進去的數值都是 NaN -- 來自 numpy 的常數, 表示 not-a-number。

補齊之後, 程式至少可以執行, 不會出現 exception。 可是運算式裡凡有一個值是 NaN, 很可能結果就是 NaN, 於是等一下會衍生出一整片 NaN ... 所以在 rid_nan() 裡面, 我們呼叫 interpolate() 函數 (內插法補值) 勉強找個大約可能有意義的值取代每個 NaN。 它預設採用 method='linear' 也就是線性內插。 對別的技術指標有沒有意義我也不知道; 不過反正我目前只會看移動平均 (sma), 這樣的內插法應該... 還算 ok 吧? 內插法必須靠左右兩邊的數值來猜測中間的數值。 那如果最左邊或最右邊有一小段全都是 NaN 呢? 沒問題, 那就再抓最接近的非 NaN 來充場面吧。 從 這個問答 學到用 next 的寫法搜尋 「第一筆/最後一筆非 NaN」。 把兩頭都照顧好了, 才真的呼叫 interpolate()。

六、 心得

這兩年學習 python 是為了跟上時代; 其實 perl 語言才是我長年以來的最愛。 寫 python 的時候, 一直很懷念 perl 程式的精簡。 例如未設定初始值的變數總是很自動地被當成 0 或空字串或空陣列。 又例如 if sid in ok_sids ... 那四句, 如果改用 perl, 只需要一句話。

本來以為 pandas 函式庫犧牲陽春陣列與陽春 dict 的效率, 可以換來類似 perl 的簡潔有力程式碼。 撰寫這個程式的過程當中, 才發現不僅執行速度慢、 程式碼並沒有變得比較簡潔, 而且還要花很多力氣爬文學習複雜的 API。 甚至連陽春 python 的優點 -- 照著 pythonic 的精神與原則寫程式, 可讀性會很高 -- 都被掩蓋掉了。 例如 存取一段資料 這件最常做的事, 就有 很多寫法, 效果各不相同。

以後啊, 還是避開 pandas, 盡量採用 python 的陽春資料結構比較實在。 等到其他函式庫堅持要讀 pandas 的資料結構時, 再臨時轉格式給它。

不過這趟旅程並沒有白走, 至少我很認真地學過一點 pandas, 有一點資格可以評論它, 而且也產出了這篇文章, 相信對 pandas 初學者會有很大的幫助。

沒有留言:

張貼留言