nikolaで多国語ポスト対応

nikolaで多国語ポスト対応

nikola handbookのMultilingual postsの項を見ながら多国語対応の設定を行う。

基本的な設定

記事や固定ページの多国語対応を行う場合、conf.pyTRANSLATIONS_PATTERNで指定されたパターンに沿ったファイル名で言語ごとに記事を作成することになる。デフォルトだと

TRANSLATIONS_PATTERN = "{path}.{lang}.{ext}"

という指定になっているため、この記事(12.md)であれば英語ポストを作成する場合は12.en.mdというファイル名を付けることとなる。

上記パターンをどの国の言語に対応させるかどうかはTRANSLATIONSの設定を変更する。この設定では各言語をkeyとしてprefixとなるパスをvalueに持つdictを設定する。デフォルトではDEFAULT_LANG : ""のみが指定されているため、新たに追加したい言語とprefixを次にような形で指定する。

TRANSLATIONS = {
    DEFAULT_LANG: "",
    "en": "./en"
}

この設定を行った上でnikola buildを実行すると、過去の諸々のポストに対しても多言語対応のための再生成が走るためかブログ構築までに5分ほどを要する。構築後、nikola serve -bでローカルのブラウザ上でブログを閲覧すると右上にEnglishという表示が増える(3カ国語以上設定した場合は試していないがおそらく選択できるようになると思われる)。 独自ドメインを設定している場合はこのEnglishをクリックすると存在しないページにアクセスすることになるため、ローカルサーバ上でアクセスするためにはアドレスバーに127.0.0.1:8000/enと手動で打ち込む必要がある。このページにアクセスすると、英語版のページが表示される。 しかし、上記設定だけでは特に翻訳版の記事を用意していないため、ポストについては日本語版と全く同一の記事が表示され、また、それ以外の上部メニュー(ナビゲーションバー)なども翻訳されず日本語のままとなってしまっている。

以下ではこれらの多国語化について記す。多言語対応可能な設定項目についてはconf.pyにおいて(translatable)というコメントが添えられているため、基本的にはこれらの設定項目を調整していくことになる。

ナビゲーションバーの多言語化

ナビゲーションバーについてはNAVIGARTION_LINKSの設定を次のように変更する。

NAVIGATION_LINKS = {
    DEFAULT_LANG: (
        ("/archive.html", "文書一覧"),
        ("/categories/", "タグ"),
        ("/pages/about/index.html", "About"),
        ("/rss.xml", "RSSフィード"),
    ),
    "en": (
        ("/en/archive.html", "all posts"),
        ("/en/categories/", "tag"),
        ("/en/pages/about/index.html", "about"),
        ("/en/rss.xml", "RSS feed"),
    )
}

ここでは各言語をkeyとし、対応するパスとリンク名のタプルのタプルをvalueとするdictを設定している。 "en"に対して"/en"で始まるパスを指定していることに注意。

タグ、カテゴリの多言語化(調査中)

nikola v8ではポストごとに複数の分類を与えるタグ(tags)と、単一の分類を与えるカテゴリ(categories)が提供されている。現時点で当ブログはタグしか用いていない。なお前のポストにも記したが、nikolaのversion 7まではセクション(sections)という分類方法も存在していたが、これはversion 8以降は削除されている(nikola handbookにはこの機能はCATEGORY_DESTPATHの設定を用いて実現可能との記載がある)。 タグやカテゴリの多言語化の仕方であるが、conf.pyを見るとTAG_TRANSLATIONCATEGORY_TRANSLATION

   [
     {'en': 'private', 'de': 'Privat'},
     {'en': 'work', 'fr': 'travail', 'de': 'Arbeit'},
   ]

のような辞書のリストを指定すれば良いとの記載がある。しかしこれを日本語と英語の間の翻訳で試みたところ、英語ページにおいても日本語タグが指定した英語タグに翻訳されず日本語タグのままとなる現象を確認している。この現象の解決のためには調査を行う必要があるため、現時点ではタグ、カテゴリの多言語化は実現できていない。

多言語ポストの混在

多言語ブログを作成する場合、基本的に日本語で書いた記事は英語版では表示させず、翻訳を行った記事のみを英語版で表示するようにしたい。しかし、nikolaでは現状そのような制御を行うことができない。 このissueにおいて同様な要望が上がっているが、しばらくは対応されなさそうな雰囲気がある。

その他

  • 日本語タグに対応するURLが漢字部分については中国語の発音として変換されているが、これは依存ライブラリであるunidecodeのせいである。これはutils.pyに実装されているslugifyを呼ぶ際に呼び出される。このunidecodeであるが、元はperlのモジュールなのであるが、この説明において日本語では正しくない結果が出力されるだろうとの記載がある。したがって解決するためには日本語だけ特別扱いし、更にmecabなどで読み付与を行うといった作業が必要となる。

結論

現状nikolaで多言語サイトを作成する場合は中途半端な多言語サイトしか実現できないのが実情である。余裕があれば原因調査の上でこの記事についても追記を行なっていきたい。

nikolaのバージョンをv8.0.0にアップグレード

nikolaのバージョンをv8.0.0にアップグレード

nikola v8.0.0

2018/9/11にnikolaのメジャーバージョンが8.0にアップグレードされた。pip install -U "Nikola[extras]"を実行しアップグーレドを施したところ、各種設定を変更しなければならなかったためその作業ログを以下に記す。

各種設定変更のためにはUpgrading to Nikola v8を参照すると良い。 ここでは単純にpip install -U "Nikola[extras]"を実行してしまったが、実際にはHow to upgradeの手順を読んだ上でアップグレードを行ったほうが良いと思われる。

conf.pyの修正

nikola --versionをブログが存在するディレクトリで実行したところ下記の警告が出た。

[2018-09-16T03:11:45Z] WARNING: Nikola: The UNSLUGIFY_TITLES setting was renamed to FILE_METADATA_UNSLUGIFY_TITLES.
[2018-09-16T03:11:45Z] WARNING: Nikola: The sections feature has been removed and its functionality has been merged into categories.
[2018-09-16T03:11:45Z] WARNING: Nikola: For more information on how to migrate, please read: https://getnikola.com/blog/upgrading-to-nikola-v8.html#sections-were-replaced-by-categories
[2018-09-16T03:11:45Z] INFO: Nikola: Setting CATEGORY_DESTPATH_AS_DEFAULT = True
[2018-09-16T03:11:46Z] WARNING: Nikola: Cannot load theme "bootstrap3", using 'bootblog4' instead.
... 以後bootstrap3が存在しない旨を示すpythonのTracebackが表示される ...

これらのwarningを修正するために、下記に記した内容を実行した。

名称が変更された設定の変更

手元ではUNSLUGIFY_TITLESFILE_METADATA_UNSLUGIFY_TITLESに変更するのみで前述の最初のWARNINGは消えた。 どうやらUpgrading to Nikola v8を参照すると他項目についても変更されたものがある模様。以下に抜粋する。

  • 削除されたもの
    • FEED_PREVIEWIMAGE
    • SITEMAP_INCLUDE_FILELESS_DIRS
    • USE_OPEN_GRAPH
    • USE_BASE_TAG
  • 名称が変更されたもの
    • UNSLUGIFY_TITLES -> FILE_METADATA_UNSLUGIFY_TITLES
    • TAG_PAGES_TITLES -> TAG_TITLES
    • TAG_PAGES_DESCRIPTIONS -> TAG_DESCRIPTIONS
    • CATEGORY_PAGES_TITLES -> CATEGORY_TITLES
    • CATEGORY_PAGES_DESCRIPTIONS -> CATEGORY_DESCRIPTIONS
    • DISABLE_INDEXES_PLUGIN_INDEX_AND_ATOM_FEED -> DISABLE_INDEXES and DISABLE_MAIN_ATOM_FEED
    • DISABLE_INDEXES_PLUGIN_RSS_FEED -> DISABLE_MAIN_RSS_FEED

実際には他にも変更された項目が存在するため、調査した結果を末尾に示してある。

セクション(Sections)関連の設定の変更

v8からはセクション関連の機能が削除されカテゴリに統合されたため、関連設定を削除する必要がある。 Upgrading to Nikola v8には書かれていないが、そもそもPOSTS_SECTIONSの項目を消す必要がある。 これによって前述のログに存在したセクション関連の警告は消失する。

デフォルトテーマの変更

アップグレード前はデフォルトテーマのbootstrap3を使っていたが、v8ではデフォルトテーマがbootblog4に変更された。 Upgrading to Nikola v8によればnikola theme -i bootstrap3を実行しテーマをインストールすることによってbootstrap3は依然として使用できるようであるが、新デフォルトテーマのbootblog4ではfeatured postsの表示などができるなど機能が追加されているとのことである。

ここではbootblog4への変更を行った。前述のログでは最後の部分でpythonのTracebackが大量に表示されていたため、この修正も合わせて行った。以下に修正のために実施した内容を順に記す。

  • conf.pyTHEMEbootblog4に変更
  • nikola buildを実行
    • nikola --versionを実行したときと同様なbootstrap3が存在しないというExceptionが表示され、再構築を行うことができない
  • キャッシュが影響していることを疑いcacheディレクトリと__pycache__ディレクトリを削除
    • 状況は変化しない
  • nikola theme -i bootstrap3を実行
    • 依然としてbootstrap3が存在しないというExceptionが表示されるためbootstrap3を再度導入することもできない
  • Exceptionを吐いているsite-packages/nikola/utils.pyのソースを調査
    • エラーを吐いているget_asset_pathのメソッドにおいてthemesのディレクトリを走査していることが問題の模様
  • ブログルート直下のthemesディレクトリを他の場所に一時的に退避
    • Exceptionを吐くエラーが消失
  • nikola buildを実行
    • 無事にブログが再構築

したがって、theme関連のトラブルがなければconf.pyの修正のみで済む内容であったと思われる。

その他conf.pyの変化

折角なので新規にnikola initを実行して生成したブログのconf.pyとのdiffを取ってその他の変更を調べた。 コメントの変更や前述した変更については記載していない。また各項目の意味については全て把握していないため必要な場合は新規生成したconf.pyに付与されたコメントなどを読んだほうが良い。

  • from __future__ import unicode_literalsの削除
  • 対応言語の追加
    • ml, th, vi
  • テーマ依存設定
    • THEME_CONFIGが追加されており、bootblog4テーマはこの設定によりfeatured postの設定やsidebarの設定が行えるようである
  • DATE_FORMATの変更
    • 元はdatetime.datetime.strftimeで用いられる形式だったがCLDRで用いられるフォーマットに変更されたとのこと
  • LOCALE_FALLBAKLOCALE_DEFAULTの削除
  • セクション関連の項目の削除
    • WRITE_TAG_CLUD, POST_SECTIONS, POST_SECTIONS_ARE_INDEXED, SECTION_PATH, POSTS_SECTION_COLORS, POSTS_SECTION_DESCRIPTIONS, POSTS_SECTION_FROM_META, POSTS_SECTOIN_NAME, POSTS_SECTION_TITLE, POSTS_SECTION_TRANSLATIONS, POSTS_SECTION_TRANSLATIONS_ADD_DEFAULTS
  • カテゴリー関連の項目の追加
    • CATEGORY_DESTPATH_AS_DEFAULT, CATEGORY_DESTPATH_TRIM_PREFIX, CATEGORY_DESTPATH_FIRST_DIRECTORY_ONLY, CATEGORY_DESTPATH_NAMES, CATEGORY_PAGES_FOLLOW_DESTPATH
  • RSS関連の項目のの追加
    • RSS_EXTENSION
    • RSS_FILENAME_BASE
    • ATOM_PATH
    • ATOM_FILENAME_BASE
    • ATOM_EXTENSION
  • USE_BASE_TAGの削除
  • LESS, SASS関連設定の削除
    • LESS_COMPILER, LESS_OPTIONS, SASS_COMPILER, SASS_OPTIONS
  • PRESERVE_ICC_PROFILESの追加
  • ANNOTATIONSの削除
  • MARKDOWN_EXTENSION_CONFIGSの追加
  • METADATA_VALUE_MAPPINGの追加
  • NO_DOCUTILS_TITLE_TRANSFORMの削除
  • USE_TAG_METADATAおよびWARN_ABOUT_TAG_METADATAの追加

最尤推定によるガンマ分布のフィッティングについて

最尤推定によるガンマ分布のフィッティングについて

データのサンプル点をガンマ分布にフィッティングさせる方法として下記のminkaによる方法が知られている。 https://tminka.github.io/papers/minka-gamma.pdf

ここではMinkaの方法における低速な方法でのフィッティングをnumpy, scipyで実装した。

In [1]:
# 下記を予めインポート
import numpy as np
import scipy as sp

import scipy.special as special
import scipy.stats as stats

import matplotlib.pyplot as plt

ガンマ分布は形状母数パラメータaと尺度母数パラメータbを持って下記のように記述される。

$$ p(x|a,b)= \textrm{Ga}(x; a, b) = \frac{x^{a-1}}{\Gamma(a)b^a}\exp \left( - \frac{x}{b} \right) $$

これに従うサンプル点はpythonではscipy.stats.gammaで生成できる。ただしscipy.stats.gammaは形状母数パラメータaを明示的に指定し、尺度母数パラメータbはキーワード引数scaleで与えることとなる。

In [2]:
#  https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.gamma.html
# のサンプルコードを改変したもの
a = 1.99
b = 0.3

# パラメータaに対する平均、分散、歪度、尖度を算出
mean, var, skew, kurt = stats.gamma.stats(a, moments='mvsk', scale=b)
print("平均: {0}, 分散: {1}, 歪度: {2}, 尖度: {3}".format(mean, var, skew, kurt))

# 確率密度を計算する点を準備
# ppfは累積密度関数cdfの逆関数に相当するもの、
# 即ち下記では累積確率値が1%の点から99%の点までを100等分している
xs = np.linspace(stats.gamma.ppf(0.01, a, scale=b),
                           stats.gamma.ppf(0.99, a, scale=b), 100)

# ppfがcdfの逆関数となっていることを確認
cdf_values = [0.001, 0.5, 0.999]
ppf_points = stats.gamma.ppf(cdf_values, a, scale=b)
is_close = np.allclose(cdf_values, stats.gamma.cdf(ppf_points, a, scale=b))
print("cdf(ppf(x)) == x: {}".format(is_close))

# 確率密度関数pdfを用いてxsの各点に対する確率密度を計算
ps = stats.gamma.pdf(xs, a, scale=b)

# 確率分布オブジェクトはパラメータを固定するために関数として呼ぶことも可能
rv = stats.gamma(a, scale=b )
ps_fixed = rv.pdf(xs)

# seedを固定してガンマ分布に従う乱数値を1000点生成
seed = 1
rs = stats.gamma.rvs(a, size=1000, scale=b, random_state=seed)

# fittingを実行
# 最尤推定した形状パラメータ(a), 位置パラメータ, スケールパラメータ(b)を返す
a_hat, loc_hat, scale_hat = stats.gamma.fit(rs)
ps_hat = stats.gamma.pdf(xs, a_hat, loc=loc_hat, scale=scale_hat)
print("a_hat: {0}, loc_hat: {1}, scale_hat: {2}".format(a_hat, loc_hat, scale_hat))

# 確率分布およびヒストグラムをプロット
fig = plt.figure(1, figsize=(12, 8))
ax = fig.add_subplot(111)
ax.plot(xs, ps          , 'r-', lw=5, alpha=0.6, label='gamma pdf')
ax.plot(xs, ps_fixed, 'k-', lw=2, label='frozen pdf')
ax.plot(xs, ps_hat  , 'g-', lw=2, label='fitted pdf')

nbins = 100
ax.hist(rs, density=True, histtype='stepfilled', alpha=0.2, bins=nbins)
ax.legend(loc='best', frameon=False)

ax.grid(True)
平均: 0.597, 分散: 0.17909999999999998, 歪度: 1.4177624100166717, 尖度: 3.0150753768844223
cdf(ppf(x)) == x: True
a_hat: 1.8699287082775293, loc_hat: 0.0006612410902254742, scale_hat: 0.32608714461966015

途中、stats.gamma.fitでガンマ分布からサンプリングした乱数値から最尤推定に基づくパラメータ推定を行っているが、ここではこの最尤推定によるフィッティングは冒頭に記載したMinkaによる方法を用いることで行える。

Minkaの方法による低速なフィッティング方法では以下の文献中の(8)式を繰り返し実行することによって形状パラメータaを推定する。

$$ \Psi(a_{n+1}) = \overline{\log{x}} - \log{\overline{x}} + \log{a_n}$$

ここで$\Psi(x)$はディガンマ関数であり、上線は平均を表す。$a_{n+1}$を求めるためには、右辺の値を計算し、これに対するディガンマ関数の逆関数$\Psi^{-1}(x)$の値を下記のように計算することで求めることが可能である。

$$ a_{n+1} = \Psi^{-1}\left(\overline{\log{x}} - \log{\overline{x}} + \log{a_n}\right)$$

このdigamma関数の逆関数の実装はこの記事を参考にした(リンク先のmatlab実装をpython版として改変した)。

また、尺度パラメータbに関しては上記で推定したaを用いて$b=\overline{x}/a`$として推定が可能である。位置パラメータについてはここでは考慮していない。

In [3]:
def inv_digamma(x, niter=3):
    m = float(x >= -2.22)
    y = m * (np.exp(x) + 0.5) + (1.0 - m) * (-1.0 / (x - special.digamma(1)))
    for _ in range(niter):
        y = y - (special.digamma(y) - x) / special.polygamma(1, y)
    return y


def gamma_fit(xs, niter_a=250, a0=1.0):
    mean_xs = np.mean(xs)
    log_mean_xs = np.log(mean_xs)
    mean_log_xs = np.mean(np.log(xs))
    a_hat = a0
    for _ in range(niter_a):
        a_hat = inv_digamma(mean_log_xs - log_mean_xs + np.log(a_hat))
    b_hat = np.mean(xs) / a_hat
    
    return (a_hat, b_hat)

上記実装を用いて実際にフィッティングを行った結果を下記に記す。

In [4]:
# 実行
a_hat, b_hat = gamma_fit(rs)
print("a_hat: {0}, b_hat: {1}".format(a_hat, b_hat))
my_ps_hat = stats.gamma.pdf(xs, a_hat, scale=b_hat)


# 確率分布およびヒストグラムをプロット
fig = plt.figure(1, figsize=(12, 8))
ax = fig.add_subplot(111)
ax.plot(xs, ps          , 'r-', lw=5, alpha=0.6, label='gamma pdf')
ax.plot(xs, ps_hat  , 'g-', lw=5, alpha=0.6, label='fitted pdf')
ax.plot(xs, my_ps_hat, 'y-', lw=2, label='implemented pdf')

nbins = 100
ax.hist(rs, density=True, histtype='stepfilled', alpha=0.2, bins=nbins)
ax.legend(loc='best', frameon=False)

ax.grid(True)
a_hat: 1.8772947079419777, b_hat: 0.32516264727354144

scipy.statsfitによって推定された結果と自前実装であるgamma_fitの結果がほとんど同じになっていることが分かる。