numpy.datetime64からdatetime.datetimeへの変換

`numpy.datetime64`から`datetime.datetime`への変換について考察してみたので、メモとしてまとめておく。

:::note warn
ここではtimezoneを考慮していない。`numpy.datetime64`の[timezoneは非推奨である](https://numpy.org/doc/stable/reference/arrays.datetime.html)為。
:::

## 結論
文字列経由で変換するのが無難である。

```python:リスト1
from datetime import datetime
from numpy import datetime64

def ts2dt(ts):
    '''
    ts2dt(): datetime64 -> datetime
    datetime64('year'), datetime64('year-month') はエラーとなる
    '''
    return datetime.fromisoformat(str(ts)[0:26])

_date_re=re.compile('^[0-9]{4}-[0-9]{2}-[0-9]{2}')
_month_re=re.compile('^[0-9]{4}-[0-9]{2}$')
_year_re=re.compile('^[0-9]{4}$')

def safer_ts2dt(ts):
    '''
    safer_ts2dt(): datetime64 -> datetime
    datetime64('year'), datetime64('year-month') を変換可能
    '''
    ts_str=str(ts)[0:26]
    if not _date_re.match(ts_str):
        if _month_re.match(ts_str):
            ts_str += '-01'
        elif _year_re.match(ts_str):
            ts_str += '-01-01'
        else:
            raise(ValueError(ts_str))
    return datetime.fromisoformat(ts_str)


# datetime -> datetime64
def dt2ts(dt):
    return np.datetime64(dt.isoformat())

#
# 実行例：
print(dt2ts(ts2dt(datetime64('2022-08-01T09:00:00.123456'))))
print(ts2dt(dt2ts(datetime(2022, 8, 1, 9, 0, 0, 123456))))
print()
print(dt2ts(safer_ts2dt(datetime64('2022-08'))))
print(dt2ts(safer_ts2dt(datetime64('2022'))))

```

```:実行結果1
2022-08-01T09:00:00.123456
2022-08-01 09:00:00.123456

2022-08-01T00:00:00
2022-01-01T00:00:00
```

## 各種変換方法

#### `datetime64.astype(datetime)`で変換する方法
`datetime64`の精度が micro second までなら、`datetime64.astype(datetime)`で`datetime`に変換できる（リスト2: `micro_ts-> micro_dt`）。
ところが`datetime64`の精度が nano second まであると変換できず、
`int`になってしまう（リスト2: `nano_ts->nano_dt`）。

```python:リスト2
micro_ts = datetime64('2022-08-01T09:00:00.123456')
micro_dt = micro_ts.astype(datetime)
print(micro_ts, micro_dt, type(micro_dt), sep='\n')
print()
nano_ts = datetime64('2022-08-01T09:00:00.1234560')
nano_dt = nano_ts.astype(datetime)
print(nano_ts, nano_dt, type(nano_dt), sep='\n')
```

```:実行結果2
2022-08-01T09:00:00.123456
2022-08-01 09:00:00.123456
<class 'datetime.datetime'>

2022-08-01T09:00:00.123456000
1659344400123456000
<class 'int'>
```

#### `datetime64.astype(int)`で一旦`int`にした上で、`datetime.fromtimestamp()`を使う方法

`datetime64.astype(int)`を使えば、精度に関わらず`int`になる。これを使えば良いと思ったが...

```python:リスト3
year_ts = datetime64('2022')
print(year_ts, year_ts.astype(int))

month_ts = datetime64('2022-08')
print(month_ts, month_ts.astype(int))

day_ts = datetime64('2022-08-01')
print(day_ts, day_ts.astype(int))

hour_ts = datetime64('2022-08-01T09')
print(hour_ts, hour_ts.astype(int))

minute_ts = datetime64('2022-08-01T09:08')
print(minute_ts, minute_ts.astype(int))

second_ts = datetime64('2022-08-01T09:08:07')
print(second_ts, second_ts.astype(int))

micro_ts = datetime64('2022-08-01T09:08:07.654321')
print(micro_ts, micro_ts.astype(int))

nano_ts = datetime64('2022-08-01T09:08:07.6543210')
print(nano_ts, nano_ts.astype(int))
```

```:実行結果3
2022 52
2022-08 631
2022-08-01 19205
2022-08-01T09 460929
2022-08-01T09:08 27655748
2022-08-01T09:08:07 1659344887
2022-08-01T09:08:07.654321 1659344887654321
2022-08-01T09:08:07.654321000 1659344887654321000
```

上記の通り、`datetime64`の精度により`int`に変換した場合の桁が異なる。

#### 文字列（isoformat）を介して変換する方法
isoformatの文字列を介して変換すれば問題は起きないと考え、実行してみた。
```python:リスト4
def ts2dt(ts):
    try:
        return(datetime.fromisoformat(str(ts)[0:26]))
    except:
        return('### error ###')

year_ts = datetime64('2022')
print(year_ts, str(year_ts), ts2dt(year_ts))

month_ts = datetime64('2022-08')
print(month_ts, str(month_ts), ts2dt(month_ts))

day_ts = datetime64('2022-08-01')
print(day_ts, str(day_ts), ts2dt(day_ts))

hour_ts = datetime64('2022-08-01T09')
print(hour_ts, str(hour_ts), ts2dt(hour_ts))

minute_ts = datetime64('2022-08-01T09:08')
print(minute_ts, str(minute_ts), ts2dt(minute_ts))

second_ts = datetime64('2022-08-01T09:08:07')
print(second_ts, str(second_ts), ts2dt(second_ts))

micro_ts = datetime64('2022-08-01T09:08:07.654321')
print(micro_ts, str(micro_ts), ts2dt(micro_ts))

nano_ts = datetime64('2022-08-01T09:08:07.6543210')
print(nano_ts, str(nano_ts), ts2dt(nano_ts))
```

```:実行結果
2022 2022 ### error ###
2022-08 2022-08 ### error ###
2022-08-01 2022-08-01 2022-08-01 00:00:00
2022-08-01T09 2022-08-01T09 2022-08-01 09:00:00
2022-08-01T09:00 2022-08-01T09:00 2022-08-01 09:00:00
2022-08-01T09:00:00 2022-08-01T09:00:00 2022-08-01 09:00:00
2022-08-01T09:00:00.123456 2022-08-01T09:00:00.123456 2022-08-01 09:00:00.123456
2022-08-01T09:00:00.123456000 2022-08-01T09:00:00.123456000 2022-08-01 09:00:00.123456
```

上記から分かる通り、`datetime64`が"年"あるいは"年-月"の場合、`datetime.fromisoformat()`がエラーとなってしまう。ただしそのような`datetime64`が現れない事も多い。よって[リスト1](#結論)にある通り、2通りの関数とした。
[リスト1](#結論)の`safer_ts2dt()`では、`datetime64`を文字列に変換した際に'年`, '年-月'であった場合、'年-月-日'となるように補完している。

## benchmark
以下のようなスクリプトを用意し、[`pytest-benchmark`](https://pytest-benchmark.readthedocs.io/en/stable/index.html)にてベンチマークを取ってみた。

```python:test_ts2dt.py
#!/usr/bin/env python3

from datetime import datetime
from numpy import datetime64
import pytest
import re

#
# astype_ts2dt()
# datetime64.astype(datetime)で変換
def astype_ts2dt(ts=datetime64('2022-04-01T09:00:00')):
    return ts.astype(datetime)

def test_astype_ts2dt(benchmark):
    # テスト対象を引数として benchmark を実行する
    ret = benchmark(astype_ts2dt)
    # 返り値を検証する
    assert type(ret) is datetime

#
# str_ts2dt()
# 文字列経由で変換
def str_ts2dt(ts=datetime64('2022-04-01T09:00:00')):
    return datetime.fromisoformat(str(ts)[0:26])

def test_str_ts2dt(benchmark):
    # テスト対象を引数として benchmark を実行する
    ret = benchmark(str_ts2dt)
    # 返り値を検証する
    assert type(ret) is datetime

#
# safer_ts2dt()
# 文字列経由で変換。datetime64('年'), datetime64('年-月')対応
_date_re=re.compile('^[0-9]{4}-[0-9]{2}-[0-9]{2}')
_month_re=re.compile('^[0-9]{4}-[0-9]{2}$')
_year_re=re.compile('^[0-9]{4}$')

def safer_ts2dt(ts=datetime64('2022-04-01T09:00:00')):
    ts_str=str(ts)[0:26]
    if not _date_re.match(ts_str):
        if _month_re.match(ts_str):
            ts_str += '-01'
        elif _year_re.match(ts_str):
            ts_str += '-01-01'
        else:
            raise(ValueError(ts_str))
    return datetime.fromisoformat(ts_str)


def test_safer_ts2dt(benchmark):
    # テスト対象を引数として benchmark を実行する
    ret = benchmark(safer_ts2dt)
    # 返り値を検証する
    assert type(ret) is datetime

# 
# main

if __name__ == '__main__':
    pytest.main(['-v', __file__])

```

結果は意外にも、**`datetime64.astype()`を用いる方が文字列経由で変換するより遅い**と言う結果になった。

```:実行例
$ pytest --benchmark-disable-gc                                                                                                                +[master]
========================================================================================== test session starts ===========================================================================================
platform darwin -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0
benchmark: 3.4.1 (defaults: timer=time.perf_counter disable_gc=True min_rounds=5 min_time=0.000005 max_time=1.0 calibration_precision=10 warmup=False warmup_iterations=100000)
rootdir: /Users/akihiro/src/Project/mpl/cputemp/tests2
plugins: benchmark-3.4.1
collected 3 items

test_ts2dt.py ...                                                                                                                                                                                  [100%]


---------------------------------------------------------------------------------------- benchmark: 3 tests ----------------------------------------------------------------------------------------
Name (time in ns)          Min                    Max                Mean              StdDev              Median                IQR            Outliers  OPS (Mops/s)            Rounds  Iterations
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_str_ts2dt        254.1500 (1.0)       3,604.2000 (1.0)      264.9232 (1.0)       16.4015 (1.0)      262.5000 (1.0)       4.1500 (1.0)    2428;17735        3.7747 (1.0)      152882          20
test_safer_ts2dt      416.0000 (1.64)     19,209.0000 (5.33)     479.8195 (1.81)      91.7862 (5.60)     459.0000 (1.75)     42.0000 (10.12)     793;793        2.0841 (0.55)      76676           1
test_astype_ts2dt     750.0000 (2.95)     45,542.0000 (12.64)    840.0710 (3.17)     410.1020 (25.00)    833.0000 (3.17)     42.0000 (10.12)     62;1482        1.1904 (0.32)      31373           1
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean
=========================================================================================== 3 passed in 2.58s ============================================================================================
```
なお上記は実行例の一つに過ぎないが、何度か実行してみた範囲ではこの順序は変わらなかった。`--benchmark-disable-gc`無しで実行しても、最小値(Min)と平均(Mean)は常に`str_ts2dt() < safer_ts2dt() < astype_ts2dt()`であった。
よって`str_ts2dt()`または`safer_ts2dt()`を使う方が望ましいと言えるだろう。

## 参考
https://qiita.com/Kanahiro/items/3ed1372358735c83884c

https://numpy.org/doc/stable/reference/arrays.datetime.html

https://pytest-benchmark.readthedocs.io/en/stable/index.html