PythonからProphetを使用する際にGPL依存を回避する

# 背景

ProphetはMeta(旧Facebook)社が開発しているOSSの時系列予測のライブラリで、ARモデルなどのように相関を持った時系列を扱うのではなくGeneralized Additive Modelという方法で各サンプルを独立なデータとして回帰モデルで扱うアプローチをとっています。

Prophetは内部ではStanという確率プログラミングのライブラリを使用していて、特にPyStanというインターフェースを使用しています。PyStanはv2系とv3系があり、Prophetは機能が削られたv3ではなくv2を使用しています。そしてこのPyStan v2はGPLライセンスです。

Prophetの開発陣もこの問題を認識しており[^1]、すでに非GPLのCmdStanPyという別のインターフェースが試験的に使える形になっておりますが、依然としてProphetのインストール時にv2系のPyStanが強制的にインストールされていて本当にGPLを回避できているのかわからず気持ち悪いのでPyStanを必須の依存ライブラリから取り除き、CmdStanPyを簡単に使えるようにインストール手順も調整しました。

[^1]: https://github.com/facebook/prophet/issues/1221

# 成果物

- GitHub: https://github.com/lucidfrontier45/prophet-nogpl
- PyPI: https://pypi.org/project/prophet-nogpl/

`pip install prophet-nogpl` や `poetry add prophet-nogpl`でインストールでき、使用するときには`prophet`のままimportできます。このライブラリ自体はプラットフォーム依存はありませんが、上で書いたようにモデルファイルと共有ライブラリはLinux x86_64以外の場合は自分でビルドして`$HOME/.prophet`以下に配置する必要があります。コンパイルはGitHubレポジトリにある`compile_cmdstanpy_model.py`を使用してください。


# 方法

成果物の`prophet-nogpl`の内容を説明します。

## プロジェクト構成

公式のレポジトリではプロジェクト構成は以下のようになっています。これをまずはそのままforkしてきます。

```
root/
  docs/
  examples/
  notebooks/
  python/
    prophet/
  python_shim/
  R/
  ...
```

`root/python` 以下が今回は関係する場所です。このディレクトリの下に`requirements.txt`があり、その中にがっつり

```
Cython>=0.22
cmdstanpy==0.9.77
pystan~=2.19.1.1
numpy>=1.15.4
pandas>=1.0.4
...
```
のようにPyStan v2系が指定されています。今回はこれを用いずに`root/`以下に`pyproject.toml`を配置し、Poetryを使用してプロジェクトを管理する方式を採用します。

```toml
[tool.poetry]
name = "prophet-nogpl"
version = "1.0.0"
description = ""
authors = ["Du Shiqiao <lucidfrontier.45@gmail.com>"]
packages = [
    { include = "prophet/*.py", from = "python"}
]

[tool.poetry.dependencies]
python = ">=3.8,<4"
tqdm = "^4.63.0"
python-dateutil = "^2.8.2"
holidays = "^0.13"
convertdate = "^2.4.0"
LunarCalendar = "^0.0.9"
pandas = "^1.4.1"
matplotlib = "^3.5.1"
cmdstanpy = "^1.0.1"
requests = "^2.27.1"
```

ポイントとしては `[tool.poetry]`の `packages` で含めるべき`prophet`のディレクトリを指定し、`[tool.poetry.dependencies]`ではPyStanを除外しておきます。なお、公式のプロジェクトではCmdStanPyは0.9.77に固定でしたが、1.0以降でも特に問題がなかったのでそちらを使用しています。

## モデルの修正

モデルは `python/prophet/models.py`に`CmdStanPyBackend`というクラスがあってこれが今回の修正対象です。これの中の`load_model`というメソッドが特に重要です。以下がオリジナルの内容です。

```py
    def load_model(self):
        import cmdstanpy
        self._add_tbb_to_path()
        model_file = pkg_resources.resource_filename(
            'prophet',
            'stan_model/prophet_model.bin',
        )
        return cmdstanpy.CmdStanModel(exe_file=model_file)
```

このように `lib/python-<versoin>/site-packages/prophet/stan_model/prophet_model.bin` という実行ファイルを指定していることがわかります。このファイルはProphetをインストールする際に`setup.py`内で定義された方法でビルドされ、上記のフォルダにコピーされます。また、`ldd`コマンドで確認すると`libtbb.so.2`という共有ライブラリも必要であることがわかります。こちらはCmdStanのフォルダー内にあります。したがって、これらを事前に用意しておいてインターネット上に配置しておき、初回の実行時にダウンロードしてくるというアプローチをとることでインストール時のコンパイルの時間を削減でき、かなりストレスも低減できます。

修正版では以下のようにしました。

```py
_model_dir_path = Path(os.environ.get("PROPHET_MODEL_DIR_PATH", default=Path.home().joinpath(".prophet")))

class CmdStanPyBackend(IStanBackend):

    def download_model_files(self):
        if (platform.system(), platform.machine()) != ("Linux", "x86_64"):
            logger.warning("only Linux x86_64 binary can be downloaded")
            logger.warning("please prepare compiled stan model binary by yourself")
            return

        import requests
        import shutil

        if not _model_dir_path.exists():
            _model_dir_path.mkdir()

        targets = {
            "libtbb.so.2": "https://github.com/lucidfrontier45/prophet-nogpl/releases/download/1.0.0/libtbb.so.2",
            "prophet_model.bin": "https://github.com/lucidfrontier45/prophet-nogpl/releases/download/1.0.0/prophet_model.bin"
        }

        for file_name, url in targets.items():
            target_path = _model_dir_path.joinpath(file_name)
            if not target_path.exists():
                logger.info(f"downloading {file_name}")
                with requests.get(url, stream=True) as r:
                    with target_path.open("wb") as f:
                        shutil.copyfileobj(r.raw, f)
                        target_path.chmod(0o755)

    def _add_tbb_to_path(self):
        """Add the TBB library to $PATH on Windows only. Required for loading model binaries."""
        if PLATFORM == "win":
            tbb_path = pkg_resources.resource_filename(
                "prophet",
                f"stan_model/cmdstan-{self.CMDSTAN_VERSION}/stan/lib/stan_math/lib/tbb"
            )
            os.environ["PATH"] = ";".join(
                list(OrderedDict.fromkeys([tbb_path] + os.environ.get("PATH", "").split(";")))
            )
        elif PLATFORM == "unix":
            tbb_path = str(_model_dir_path)
            old = os.environ.get("LD_LIBRARY_PATH")
            if old:
                os.environ["LD_LIBRARY_PATH"] = old + ":" + tbb_path
            else:
                os.environ["LD_LIBRARY_PATH"] = tbb_path    

    def load_model(self):
        self.download_model_files()
        import cmdstanpy
        self._add_tbb_to_path()
        model_file = str(_model_dir_path.joinpath("prophet_model.bin"))
        return cmdstanpy.CmdStanModel(exe_file=model_file)
```

`download_model_files`はGitHubのリリースページに配置しておいたビルド済みの`prophet_model.bin`と`libtbb.so.2`をダウンロードして環境変数`PROPHET_MODEL_DIR_PATH`で指定したフォルダ(デフォルトは`$HOME/.prophet`)に配置する関数です。`load_model`は`download_model_files`を呼び出した後にそれらのファイルを読み込むようにしています。共有ライブラリをちゃんと読み込むように`_add_tbb_to_path`で`LD_LIBRARY_PATH`にも動的に追加しています。

なお、GitHubにはLinux x86_64向けのバイナリしか配置しておりません。それ以外の場合はCmdStan、CmdStanPy、C++コンパイラーをインストール後に以下のスクリプトを使用してビルドしてください。

```py
import os.path
import shutil

import cmdstanpy

model_dir = "python/stan/unix"
model_name = "prophet.stan"
target_name = "prophet_model.bin"
sm = cmdstanpy.CmdStanModel(
    model_name=model_name,
    stan_file=os.path.join(model_dir, model_name),
    stanc_options={"O1": True},
    cpp_options={"O3": True},
)
cmdstan_path = cmdstanpy.cmdstan_path()
tbb_path = os.path.join(cmdstan_path, "stan/lib/stan_math/lib/tbb/libtbb.so.2")

shutil.copy(os.path.join(model_dir, "prophet"), target_name)
shutil.copy(tbb_path, "libtbb.so.2")
```