祝日判定 jholiday.py の改良
2010/3/27 文章全般を見直した。追記を繰り返した結果、読みづらいものとなっていたため。
先日、画像にカレンダーを描画するスクリプトを作ったのだけれども。カレンダーには祝日情報が要る、と思ったのが発端。
そして、祝日判定スクリプト jholiday.py に再びお世話になった。そして、このスクリプトについて気になる点が 2 つ、試してみたいことが 1 つみつかった。
sholiday.py
jholiday.py の私的改造版 sholiday.py はこれから書く 3 つの変更を加えたもの。 holiday_name 関数の入出力は同じなので jholiday.py と置き換えることが可能。
ちなみに、同梱してある test_sholiday.py はオリジナルの jholiday.py と動作が変わらないことを確認するためのものであるため、 Python 2 の -Qold オプション(デフォルト)環境で動かす必要がある。
ダウンロード
変更履歴
- version 1.1.4 (2009/11/19) 。テストの微修正。
- version 1.1.3 (2009/11/18) 。(year, month, day) == (1989, 2, 24) のようなタプル比較を year == 1989 and day == 24 に変更してスピードアップ。効果はごくわずか。
- version 1.1.2 (2009/11/18)。 int((year - 1980) / 4)) のようなコードについて。int 関数は不要なので削除し / 演算を // 演算に置き換えた。これは Python 3 対策も兼ねている。オリジナルの jholiday.py は 2to3.py にかけた後、 Python 3 で動かすとここで異常動作する。
- version 1.1.1 (2009/11/17)。キャッシュ範囲を狭めたことにより、 2001年(CACHE_START_DAY) 以前の祝日判定がすべて None になるバグがあったのでバグ退治。
- version 1.1 (2009/11/15 12:40)。 1948 年から 2100 年までをキャッシュしていたが、期間が長すぎるので 2001年から 2020年までに縮めた。キャッシュする日付の範囲の変更は CACHE_START_DAY 、 CACHE_END_DAY という モジュールグローバル変数を書き換えておくことでできる(ソースを書き換える必要あり。モジュールとしてインポートし終わってから動的に書き換えても変更されない)。
名前について
日本(Japan)の祝日(HOLIDAY)ではなくて、シュン(Shun)の祝日(HOLIDAY)。もしくはスピードアップ(Speed up)した日本の祝日(HOLIDAY)判定。
jholiday.py とは?
AddinBox の VB/VBA 用祝日判定ルーチンを JavaScript に移植したものをさらに Python に移植したもの。とっても便利。
>>> from jholiday import holiday_name >>> print holiday_name(2009, 11, 23) 勤労感謝の日
整数の割り算の問題
jholiday.py には、 int(a / b) と書いて切り捨て除算を期待している箇所がある。しかし、このコードには疑問となる点がある。
- Python 2 では a / b で双方がともに整数の場合、切り捨て除算となり整数が返るので int はなくてもよく、単に a / b でよい。
- Python 3 では a / b で双方がともに整数の場合、実数の近似解が返る。悪いことに int はゼロに近いほうに値を丸めるので int(a / b) では切り捨て除算になってくれないことがある。
Python 2.2 (2001年) 以降には floor division 演算子「//」があり、古い「/」演算子と同じ演算結果が得られるようになっている。このため、切り捨て除算を期待する場合、「//」演算子を用いるべきである。「//」演算子ならば Python 2.2 以降、 Python 3 双方で切り捨て除算をしてくれる。
検証
Python 2.2 以降で -Qnew オプションを付けて jholiday.py を使うか、 Python 3 で 2to3.py で変換した jholiday.py を使うと動作が異なることが確認できる。
Python 2.6.2
Python 2.6.2 (r262:71605, Apr 14 2009, 22:40:02) [MSC v.1500 32 bit (Intel)] on win32 Type "help", "copyright", "credits" or "license" for more information. >>> import jholiday >>> print jholiday.holiday_name(1949, 9, 23) 秋分の日
Python 2.6.2 -Qnew オプションあり
Python 2.6.2 (r262:71605, Apr 14 2009, 22:40:02) [MSC v.1500 32 bit (Intel)] on win32 Type "help", "copyright", "credits" or "license" for more information. >>> import jholiday >>> print jholiday.holiday_name(1949, 9, 23) None >>> print jholiday.holiday_name(1949, 9, 22) 秋分の日
Python 3.1.1
Python 3.1.1 (r311:74483, Aug 17 2009, 17:02:12) [MSC v.1500 32 bit (Intel)] on win32 Type "help", "copyright", "credits" or "license" for more information. >>> import jholiday_py3 as jholiday >>> print(jholiday.holiday_name(1949, 9, 23)) None >>> print(jholiday.holiday_name(1949, 9, 22)) 秋分の日
動作が異なる原因は「/」演算子が Python 2 と Python 3 で異なるため*1である。
Python 2 における除算では、「/」「//」ともに整数が返る。これらに違いはない。割り切れない際の値の丸め方向はより小さい値のほうである。
>>> 30 / 4 7 >>> 30 // 4 7 >>> -30 / 4 -8 >>> -30 // 4 -8
そして、これが Python 3 における除算*2。「/」では実数の近似解が得られる。「//」では整数が返る。丸め方向は小さい値のほうである。
>>> 30 / 4 7.5 >>> 30 // 4 7 >>> -30 / 4 -7.5 >>> -30 // 4 -8
int による浮動小数点数の丸め方向は 0 に近い値のほうである。この丸め方向の違いが int(a / b) と a // b の違いとなってしまうことがある。
>>> int(7), int(7.5) (7, 7) >>> int(-8), int(-7.5) (-8, -7)
結論、切り捨て除算をおこなうには「//」演算子を使うこと。
パッチ
*** jholiday.py Sun Feb 15 11:11:31 2009 --- jholiday_floordivision.py Wed Nov 18 21:49:33 2009 *************** *** 54,64 **** if y <= 1947: d = 0 elif y <= 1979: ! d = int(20.8357 + 0.242194 * (y - 1980) - int((y - 1980) / 4)) elif y <= 2099: ! d = int(20.8431 + 0.242194 * (y - 1980) - int((y - 1980) / 4)) elif y <= 2150: ! d = int(21.8510 + 0.242194 * (y - 1980) - int((y - 1980) / 4)) else: d = 0 --- 54,64 ---- if y <= 1947: d = 0 elif y <= 1979: ! d = int(20.8357 + 0.242194 * (y - 1980) - (y - 1980) // 4) elif y <= 2099: ! d = int(20.8431 + 0.242194 * (y - 1980) - (y - 1980) // 4) elif y <= 2150: ! d = int(21.8510 + 0.242194 * (y - 1980) - (y - 1980) // 4) else: d = 0 *************** *** 70,80 **** if y <= 1947: d = 0 elif y <= 1979: ! d = int(23.2588 + 0.242194 * (y - 1980) - int((y - 1980) / 4)) elif y <= 2099: ! d = int(23.2488 + 0.242194 * (y - 1980) - int((y - 1980) / 4)) elif y <= 2150: ! d = int(24.2488 + 0.242194 * (y - 1980) - int((y - 1980) / 4)) else: d = 0 --- 70,80 ---- if y <= 1947: d = 0 elif y <= 1979: ! d = int(23.2588 + 0.242194 * (y - 1980) - (y - 1980) // 4) elif y <= 2099: ! d = int(23.2488 + 0.242194 * (y - 1980) - (y - 1980) // 4) elif y <= 2150: ! d = int(24.2488 + 0.242194 * (y - 1980) - (y - 1980) // 4) else: d = 0 *************** *** 93,99 **** name = u'元日' else: if year >= 2000: ! if int((day - 1) / 7) == 1 and date.weekday() == MONDAY: name = u'成人の日' else: if day == 15: --- 93,99 ---- name = u'元日' else: if year >= 2000: ! if (day - 1) // 7 == 1 and date.weekday() == MONDAY: name = u'成人の日' else: if day == 15: *************** *** 140,146 **** # 7月 elif month == 7: if year >= 2003: ! if int((day - 1) / 7) == 2 and date.weekday() == MONDAY: name = u'海の日' elif year >= 1996 and day == 20: name = u'海の日' --- 140,146 ---- # 7月 elif month == 7: if year >= 2003: ! if (day - 1) // 7 == 2 and date.weekday() == MONDAY: name = u'海の日' elif year >= 1996 and day == 20: name = u'海の日' *************** *** 151,157 **** name = u'秋分の日' else: if year >= 2003: ! if int((day - 1) / 7) == 2 and date.weekday() == MONDAY: name = u'敬老の日' elif date.weekday() == TUESDAY and day == autumn_equinox - 1: name = u'国民の休日' --- 151,157 ---- name = u'秋分の日' else: if year >= 2003: ! if (day - 1) // 7 == 2 and date.weekday() == MONDAY: name = u'敬老の日' elif date.weekday() == TUESDAY and day == autumn_equinox - 1: name = u'国民の休日' *************** *** 160,166 **** # 10月 elif month == 10: if year >= 2000: ! if int((day - 1) / 7) == 1 and date.weekday() == MONDAY: name = u'体育の日' elif year >= 1966 and day == 10: name = u'体育の日' --- 160,166 ---- # 10月 elif month == 10: if year >= 2000: ! if (day - 1) // 7 == 1 and date.weekday() == MONDAY: name = u'体育の日' elif year >= 1966 and day == 10: name = u'体育の日'
datetime.date インスタンスの再生成問題
日付を datetime.date で管理しているスクリプトから jholiday.py を用いようとすると、同じ日付の datetime.date が内部的に再度作られる。この問題への解決策を提案したい。
holiday_name 関数の引数は年月日の 3 整数であるため、 datetime.date で管理している日付を渡すには次のようにすることとなる。
>>> import datetime >>> from jholiday import holiday_name >>> date = datetime.date(2009, 11, 23) >>> print holiday_name(date.year, date.month, date.day) 勤労感謝の日
しかし holiday_name 関数の実装は
def holiday_name(year, month, day): date = datetime.date(year, month, day) # 以下略、判定コード
となっているのが datetime.date 再生成の原因である。
解決案
holiday_name 関数の先頭数行を書き換えることで datetime.date を引数に取るように変更する。当然、このままだと jholiday.py と動作が異なってくるので、これは holiday_name_date とリネームし、あらためて holiday_name 関数をつくる。この新 holiday_name は年月日 3 整数を datetime.date に変換し、 holiday_name_date に渡すものとする。
これで datetime.date を使うときは holiday_name_date 関数を代わりに使うことで無駄がなくなって速くなる。コードが読み書きしやすいというメリットもある。年月日の 3 整数を使う holiday_name 関数もオリジナルと互角の速度を維持できる。
>>> import datetime >>> from jholiday2 import holiday_name, holiday_name_date >>> date = datetime.date(2009, 11, 23) >>> print holiday_name_date(date) 勤労感謝の日 >>> print holiday_name(2009, 11, 23) 勤労感謝の日
パッチ
*** jholiday.py Sun Feb 15 11:11:31 2009 --- jholiday_date.py Sat Nov 14 16:46:30 2009 *************** *** 81,87 **** return d def holiday_name(year, month, day): ! date = datetime.date(year, month, day) name = None if date < datetime.date(1948, 7, 20): --- 81,92 ---- return d def holiday_name(year, month, day): ! return holiday_name_date(datetime.date(year, month, day)) ! ! def holiday_name_date(date): ! year = date.year ! month = date.month ! day = date.day name = None if date < datetime.date(1948, 7, 20):
休日辞書の事前生成(メモリとのトレードオフあり)実験
http://www.h3.dion.ne.jp/~sakatsu/holiday_logic.htm#Technique には表引きの話がある。 Python の辞書へのアクセスは O(1) なので、 datetime.date をキー、祝日名を値とした辞書を用意してしまえば、メモリは喰うものの、さらに速くなるのではないかと考えていた。
とはいえ、 datetime.date のハッシュ値を求めたり、辞書から値を取り出したりするのも 0 秒ではないから、本当に速くなるかはやってみないとわからない。
なので、やってみることにした。
holiday_name_date を _holiday_name_date (アンダーバー付)にリネームし、これを用いて 2100年までの辞書 HOLIDAY_CACHE を作成。 holiday_name_date 関数を改めて作成し、範囲内ならば辞書を探索、範囲外ならば _holiday_name_date で計算して、祝日名を求めるものとした。
次のコードで速度を測ってみる。条件をそろえるため、 holiday_name_date ではなく、引数に年月日の 3 整数を用いる holiday_name を使用。対象は 1948年7月20日から 2100年1月1日まで。
# coding: utf-8 import datetime import time from sholiday import holiday_name as sh from jholiday import holiday_name as jh def bench(): CACHE_START_DAY = datetime.date(1948, 7, 20) CACHE_END_DAY = datetime.date(2100, 1, 1) #CACHE_START_DAY = datetime.date(2100, 1, 1) #CACHE_END_DAY = datetime.date(2200, 1, 1) for holiday_name in (sh, jh): t = time.clock() date = CACHE_START_DAY delta = datetime.timedelta(1) end = CACHE_END_DAY while date < end: name = holiday_name(date.year, date.month, date.day) date += delta t1 = time.clock() print t1 - t if __name__ == '__main__': bench()
出力。
0.0897700088332 0.164494750921
約 1.83 倍の速度になったのが確認できた。しかし、数十倍になったりはしなかったのが残念。
範囲外も試してみた。先ほどのコードを 2100年1月1日から 2200年1月1日間に変更して実行。
0.102718014283 0.100493498858
速度が落ちたりはしていないことが確認できた。
*1:PEP 238 日本語訳 やThe History of Python.jp: 整数の割り算の問題 も参照。
*2:Python 2 でも -Qnew オプションを付けると同じ処理となる。また、モジュール先頭で from __future__ import division すると、そのモジュール内に限りこの処理となる