銀月の符号

Python 使い見習いの日記・雑記

祝日判定 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 とは?

AddinBoxVB/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 すると、そのモジュール内に限りこの処理となる