背景

sigmoid関数は,2値分類やVAEの,出力層の活性化関数として使われる.

定義に従って実装してもいいのだが,そうするとけっこう遅いらしいので,高速化するテクニックがある.例えばword2vecだと前に記事をかいたこんなの. どのくらい変わるか比較してみた.


本題

python 3.6.4 (anaconda3-latest) で計測.

まず,愚直に計算する場合の関数定義.

def sigmoid(x):
    return 1. / (1. + np.exp(-x))

次にword2vecやfastTextの実装にある方法. -8から8までを512分割してキャッシュしておく.

SIGMOID_TABLE_SIZE = 512
MAX_SIGMOID = 8

t_sigmoid = np.zeros(SIGMOID_TABLE_SIZE)

for i in range(SIGMOID_TABLE_SIZE):
    x = (i / SIGMOID_TABLE_SIZE * 2 - 1) * MAX_SIGMOID
    t_sigmoid[i] = 1. / (1. + np.exp(-x))

def sigmoid_cache(x):
    if x >= MAX_SIGMOID:
        return 1.
    elif x <= -MAX_SIGMOID:
        return 0.
    else:
        return t_sigmoid[(int)((x + MAX_SIGMOID) * (SIGMOID_TABLE_SIZE / MAX_SIGMOID / 2))]

-10から10までを生成する一様乱数で10000個のデータを生成する. %timeit で計測したいので,関数化.

rnd = np.random.RandomState(7)
data = rnd.uniform(low=-10, high=10, size=10000)


def cal_sigmoid(data):
    for x in data:
        sigmoid(x)


def cal_sigmoid_cache(data):
    for x in data:
        sigmoid_cache(x)

まず,愚直に計算する方を計測.

%timeit cal_sigmoid(data)

24.4 ms ± 905 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

次に,cacheしておく方を計測.

%timeit cal_sigmoid_cache(data)

16.4 ms ± 441 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

後者がやっぱり速い.

cacheの生成は,実際にsigmoid呼ぶ回数の総数と比べれば,とても小さいため,今回は無視している. 問題としては,numpy.array に対しては使えないかもしれないこと.

np.vectorize 関数に渡すと numpy.array に対しても使えるようになる (助言いただいたツイート).

vectorzed_sigmoid_cache =  np.vectorize(sigmoid_cache)
%timeit sigmoid(data)

161 µs ± 6.56 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
%timeit vectorzed_sigmoid_cache(data)

8.13 ms ± 173 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

負けた. ちなみに,キャッシュしないほうをmath.expにしても負けた.


Numba

これだとつまらなかったので numba を使って高速化する.環境が少し新しくなって,python 3.7.2 (anaconda3-latest) の numba 0.42.0

といっても単純に numba@njit をつけるだけ.

import numpy as np
from numba import njit, vectorize, float64

# no cache
@njit
def sigmoid(x):
    return 1. / (1. + np.exp(-x))

# cache
SIGMOID_TABLE_SIZE = 512
MAX_SIGMOID = 8

t_sigmoid = np.zeros(SIGMOID_TABLE_SIZE)

for i in range(SIGMOID_TABLE_SIZE):
    x = (i / SIGMOID_TABLE_SIZE * 2 - 1) * MAX_SIGMOID
    t_sigmoid[i] = 1. / (1. + np.exp(-x))


@njit
def sigmoid_cache(x):
    if x >= MAX_SIGMOID:
        return 1.
    elif x <= -MAX_SIGMOID:
        return 0.
    else:
        return t_sigmoid[(int)((x + MAX_SIGMOID) * (SIGMOID_TABLE_SIZE / MAX_SIGMOID / 2))]


rnd = np.random.RandomState(7)
data = rnd.uniform(low=-10, high=10, size=10000)

@njit
def cal_sigmoid(data):
    for x in data:
        sigmoid(x)

@njit
def cal_sigmoid_cache(data):
    for x in data:
        sigmoid_cache(x)
%timeit cal_sigmoid(data)

79.5 µs ± 5.25 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
%timeit cal_sigmoid_cache(data)

391 ns ± 36.6 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

両方ともかなり高速化できたが,特にキャシュするほうが ns になっている.この場合だと np.ndarray を直接渡してもキャッシュしたほうが速い.

%timeit sigmoid(data)

95.1 µs ± 16.1 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)

雑に @vectorize とかもつけたりしたがキャシュするほうは上の方法が一番速かった.