CUBE SUGAR CONTAINER

技術系のこと書きます。

リモートサーバ上の Docker コンテナで JupyterLab を使う

今回のエントリは、以下のエントリの改訂版となる。 起動するアプリケーションを Jupyter Notebook から JupyterLab にすると共に、いくつか変更を加えた。

blog.amedama.jp

JupyterLab は従来の Jupyter Notebook を置き換えることを目的とした後継プロジェクト。 基本的な部分は Notebook を受け継ぎつつも、より扱いやすいインターフェースを提供している。

今回使う環境は次の通り。

$ cat /etc/lsb-release
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.3 LTS"
$ uname -r
4.15.0-62-generic

Docker をインストールする

まずはリポジトリを更新すると共に、過去にインストールした Docker 関連コンポーネントがあれば削除しておく。

$ sudo apt update
$ sudo apt -y remove docker docker-engine docker.io containerd runc

Docker のインストール用リポジトリを APT に登録する。

$ sudo apt -y install \
    apt-transport-https \
    ca-certificates \
    curl \
    gnupg-agent \
    software-properties-common \
    python3-pip
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
$ sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
$ sudo apt update

Docker の主要コンポーネントをインストールする。

$ sudo apt -y install docker-ce docker-ce-cli containerd.io
$ sudo docker version
Client: Docker Engine - Community
 Version:           19.03.2
 API version:       1.40
 Go version:        go1.12.8
 Git commit:        6a30dfc
 Built:             Thu Aug 29 05:29:11 2019
 OS/Arch:           linux/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          19.03.2
  API version:      1.40 (minimum version 1.12)
  Go version:       go1.12.8
  Git commit:       6a30dfc
  Built:            Thu Aug 29 05:27:45 2019
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.2.6
  GitCommit:        894b81a4b802e4eb2a91d1ce216b8817763c29fb
 runc:
  Version:          1.0.0-rc8
  GitCommit:        425e105d5a03fabd737a126ad93d62a9eeede87f
 docker-init:
  Version:          0.18.0
  GitCommit:        fec3683

ついでに Docker Compose もインストールしておく。

$ sudo pip3 install docker-compose
$ docker-compose version
docker-compose version 1.24.1, build 4667896
docker-py version: 3.7.3
CPython version: 3.6.8
OpenSSL version: OpenSSL 1.1.1  11 Sep 2018

JupyterLab を起動するイメージを用意する

今回はデーモンを Supervisord 経由で起動することにした。 JupyterLab だけを起動するなら別に直接起動してもいいんだけど、拡張性などを考えて。 %% で囲まれている部分は後で環境変数経由で置き換える。

$ cat << 'EOF' > supervisord.conf
[supervisord]
nodaemon=true

[program:jupyterlab]
command=%%CONDA_HOME%%/envs/%%CONDA_VENV_NAME%%/bin/jupyter-lab
user=%%USERNAME%%
stdout_logfile=/var/log/supervisor/jupyter-lab.log
redirect_stderr=true
autostart=True
autorestart=True
EOF

続いて Supervisord を起動するエントリーポイントとなるシェルスクリプト。 この中では主に設定ファイルの準備をしている。 JupyterLab の Web UI にトークンなしでアクセスできるようにするなど。 JupyterLab の Web UI には SSH Port Forwarding 経由でアクセスするため、アクセス制御については SSH の部分で担保する。

$ cat << 'EOF' > docker-entrypoint.sh
#!/usr/bin/env bash

set -Ceux

: "Set home directory" && {
  export HOME=/home/${USERNAME}
}

: "Replace template variables" && {
  sed -i.back \
    -e "s:%%USERNAME%%:${USERNAME}:g" \
    -e "s:%%CONDA_HOME%%:${CONDA_HOME}:g" \
    -e "s:%%CONDA_VENV_NAME%%:${CONDA_VENV_NAME}:g" \
  /etc/supervisord.conf
}

: "Jupyter configuration" && {
  if [ ! -e /home/${USERNAME}/.jupyter ]; then
    sudo -iu ${USERNAME} ${CONDA_HOME}/envs/${CONDA_VENV_NAME}/bin/jupyter notebook --generate-config
    sudo -iu ${USERNAME} sed -i.back \
      -e "s:^#c.NotebookApp.token = .*$:c.NotebookApp.token = u'':" \
      -e "s:^#c.NotebookApp.ip = .*$:c.NotebookApp.ip = '*':" \
      -e "s:^#c.NotebookApp.open_browser = .*$:c.NotebookApp.open_browser = False:" \
      -e "s:^#c.NotebookApp.notebook_dir = .*$:c.NotebookApp.notebook_dir = \"${JUPYTER_HOME}\":" \
      /home/${USERNAME}/.jupyter/jupyter_notebook_config.py
  fi
}

: "Start supervisord" && {
  supervisord -c /etc/supervisord.conf
}
EOF

インストールしたい Python のパッケージの一覧を書いておくファイル。

$ cat << 'EOF' > requirements.txt
jupyterlab
EOF

続いては、肝心の Dockerfile となる。 Python の実行環境については Miniconda でやってしまうことにした。 インストール先や仮想環境は CONDA_HOMECONDA_VENV_NAME で変更できる。

$ cat << 'EOF' > Dockerfile
FROM ubuntu:18.04

ENV CONDA_HOME=/opt/conda \
    CONDA_VENV_NAME=venv \
    JUPYTER_HOME=/mnt/jupyter

# Use mirror repository
RUN sed -i.bak -e "s%http://[^ ]\+%mirror://mirrors.ubuntu.com/mirrors.txt%g" /etc/apt/sources.list

# Install prerequisite packages
RUN apt update \
 && apt -yq dist-upgrade \
 && apt install -yq --no-install-recommends \
      sudo \
      supervisor \
      ca-certificates \
      wget

# Install miniconda
RUN wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -O /tmp/miniconda3.sh \
 && /bin/bash /tmp/miniconda3.sh -b -p ${CONDA_HOME} \
 && rm /tmp/miniconda3.sh \
 && ${CONDA_HOME}/bin/conda clean -tipsy \
 && ln -s ${CONDA_HOME}/etc/profile.d/conda.sh /etc/profile.d/

# Create virtual environment
COPY requirements.txt /tmp
RUN . /etc/profile.d/conda.sh \
 && conda create -y -n ${CONDA_VENV_NAME} python=3.7 \
 && conda activate ${CONDA_VENV_NAME} \
 && pip install -r /tmp/requirements.txt

# Expose container ports
EXPOSE 8888

# Boot process
COPY supervisord.conf /etc
COPY docker-entrypoint.sh /var/tmp
CMD bash /var/tmp/docker-entrypoint.sh
EOF

起動するときのオプションがなるべく増えないように Docker Compose の設定ファイルも作っておく。 以下では docker-compose.yml のあるディレクトリを JupyterLab の作業ディレクトリとして /mnt/jupyter にマウントしている。 必要に応じて場所は書き換えると良いと思う。

$ cat << 'EOF' > docker-compose.yml
version: "3"
services:
  jupyter-lab:
    build:
      context: .
    environment:
      - USERNAME
      - JUPYTER_HOME=/mnt/jupyter
    image: example/jupyterlab-container
    volumes:
      - /etc/passwd:/etc/passwd:ro
      - /etc/group:/etc/group:ro
      - /home:/home
      - ./:/mnt/jupyter
    ports:
      - "127.0.0.1:8888:8888"
EOF

また、パーミッション問題を避けるために上記では /etc/passwd/etc/group をコンテナにマウントしてしまっている。 このテクニックについては以下を参照のこと。

blog.amedama.jp

準備ができたらイメージをビルドする。

$ sudo docker-compose build

コンテナを起動する

コンテナを起動する前に、念のため SSH に使うポート以外をファイアウォールで閉じておく。 コンテナが bind するアドレスはループバックアドレスなのでインターネットには晒されないけど念のため。

$ sudo ufw allow 22
$ sudo ufw default DENY
$ yes | sudo ufw enable
$ sudo ufw status
Status: active

To                         Action      From
--                         ------      ----
22                         ALLOW       Anywhere                  
22 (v6)                    ALLOW       Anywhere (v6)             

JupyterLab のコンテナを起動する。

$ sudo docker-compose run \
    --name jupyter-lab-container \
    --rm \
    -e USERNAME=$(whoami) \
    --service-ports \
    jupyter-lab

起動ログにエラーがなく、次のように 127.0.0.1:8888 で Listen していれば上手くいっている。

$ ss -tlnp | grep 8888
LISTEN   0         128               127.0.0.1:8888             0.0.0.0:*       

JupyterLab の Web UI にアクセスする

これでリモートサーバ上では JupyterLab が動いたけどループバックアドレスなので疎通がない。 そこで SSH Port Forwarding を使ってリモートサーバ上のポートをローカルに引き出してくる。

今回は Vagrant を使って環境を作っているので次のようにする。

$ vagrant ssh-config > ssh.config
$ ssh -F ssh.config -L 8888:localhost:8888 default

ブラウザで localhost:8888 にアクセスする。

$ open http://localhost:8888

以下のような Web UI が見えればおっけー。 Jupyter Notebook よりも、ちょっと IDE っぽい見た目。

f:id:momijiame:20190920031044p:plain

いじょう。

Docker コンテナ内で Docker ホストと同じユーザを使う

Docker コンテナで Docker ホストのボリュームをマウントすると、パーミッションの問題が生じることがある。 これは、ホストで使っているユーザとコンテナで使っているユーザで UID と GID の不一致が起こるため。

今回は、それらの問題を解決する方法の一つとして Docker コンテナとホストで同じユーザを使う方法を試してみる。 具体的には、Docker ホストの /etc/passwd/etc/group をコンテナからマウントしてしまう。 そのため、このやり方は Docker for Mac や Docker for Windows では使えない。

典型的なユースケースは、パッケージやライブラリといった実行環境はコンテナ内に隠蔽しつつ、その他はホストの環境をなるべく使い回したいというものだろう。

使った環境は次の通り。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.3 LTS"
$ uname -r
4.15.0-62-generic

コンテナとホストでユーザが異なる場合の問題点について

まずはコンテナとホストでユーザの UID と GID が異なる場合の問題点について見ておく。

例えば Docker ホストでは次のユーザを使っているとする。

$ id
uid=1000(vagrant) gid=1000(vagrant) groups=1000(vagrant)

試しに、カレントディレクトリにファイルを作っておこう。

$ echo "Hello, World" > greet.txt
$ ls -lF
total 4
-rw-rw-r-- 1 vagrant vagrant 13 Sep 19 09:10 greet.txt

続いて Docker コンテナを起動する。 カレントワーキングディレクトリを /mnt/working にマウントしておく。

$ sudo docker run \
    --rm \
    -v $(pwd):/mnt/working \
    -it busybox

この状態ではコンテナに root ユーザでログインしている。

# whoami
root
# id
uid=0(root) gid=0(root) groups=10(wheel)

マウントしたディレクトリに移動してファイルを作ってみる。

# cd /mnt/working/
# echo "Hello, World" > greet-docker-root.txt

ディレクトリにあるファイルを確認すると、次のようになっている。 自分で作ったファイルは root:root の権限がついている。 先ほど Docker ホストから作ったファイルは 1000:1000 という UID と GID がそのまま表示されている。 これは Docker コンテナ側で、それらの ID を持ったユーザが存在しないために起きている。

# ls -lF
total 8
-rw-r--r--    1 root     root            13 Sep 19 09:14 greet-docker-root.txt
-rw-rw-r--    1 1000     1000            13 Sep 19 09:10 greet.txt

Docker ホスト側でも同様に ls してみた結果は次の通り。 こちらでも Docker コンテナで作ったファイルは root:root となっている。 ホストで作ったファイルは現在のユーザの権限がついている。

$ ls -lF
total 8
-rw-r--r-- 1 root    root    13 Sep 19 09:14 greet-docker-root.txt
-rw-rw-r-- 1 vagrant vagrant 13 Sep 19 09:10 greet.txt

念のため、root 以外でも異なる UID と GID を持ったユーザを作って確認しておこう。 次の Dockerfile では example ユーザが 10000:10000 という UID と GID を持つようにしてある。

$ cat << 'EOF' > Dockerfile
FROM ubuntu:18.04

# Non administrative username
ENV USERNAME=example

# Add non administrative user
RUN useradd -m ${USERNAME}

# Add example group
RUN groupadd -g 10000 example-users

# Change user id and group id
RUN usermod -u 10000 -g 10000 ${USERNAME}

# Change user
USER ${USERNAME}

# Boot initial process
CMD bash

EOF

イメージのビルドとコンテナの起動のために、次のように Docker Compose 用の設定ファイルも用意しておく。

$ cat << 'EOF' > docker-compose.yml
version: "3"
services:
  example:
    build:
      context: .
    image: example/permission
    volumes:
      - ./:/mnt/working
EOF

まずはイメージをビルドする。

$ sudo docker-compose build

続いてコンテナを起動する。

$ sudo docker-compose run --rm example

起動したコンテナでは、次の通り 10000:10000 の UID と GID を持ったユーザでログインしている。

$ whoami
example
$ id
uid=10000(example) gid=10000(example-users) groups=10000(example-users)

マウントしたディレクトリに移動してファイルを作ってみよう。 すると、ディレクトリに権限がないため書き込むことができない。

$ cd /mnt/working/
$ echo "Hello, World" > greet-docker.txt
bash: greet-docker.txt: Permission denied

毎回 sudo するという手もなくはないけど、めんどくさい。

コンテナで /etc/passwd/etc/group をマウントしてみる

先ほどの問題を解決すべく、コンテナから /etc/passwd/etc/group をマウントしてみる。 コンテナ側からは書き換えできないように、念のため ro (Read Only) フラグをつけておく。 /home についてはお好みで。

$ cat << 'EOF' > docker-compose.yml
version: "3"
services:
  example:
    image: ubuntu:18.04
    volumes:
      - /etc/passwd:/etc/passwd:ro
      - /etc/group:/etc/group:ro
      - /home:/home
      - ./:/mnt/working
EOF

起動時に -u オプションで実行時のユーザの UID と GID を指定できる。 次のようにすれば現在のユーザをそのまま引き継げる。

$ sudo docker-compose run \
    -u "$(id -u $USER):$(id -g $USER)" \
    --rm \
    example

コンテナ内でも、ホスト側と同じユーザとして見えることがわかる。

$ whoami
vagrant
$ id
uid=1000(vagrant) gid=1000(vagrant) groups=1000(vagrant)

これは、ユーザとグループに関する情報がコンテナとホストで共有されているため。

$ grep vagrant /etc/passwd 
vagrant:x:1000:1000:,,,:/home/vagrant:/bin/bash

先ほどと同じようにマウントされているディレクトリに移動してファイルを作ってみよう。 コンテナとホストで UID と GID が一致しているため、ちゃんと書き込むことができている。

$ cd /mnt/working/
$ echo "Hello, World" > greet-docker.txt
$ ls -lF 
total 20
-rw-rw-r-- 1 vagrant vagrant 324 Sep 19 09:18 Dockerfile
-rw-rw-r-- 1 vagrant vagrant 183 Sep 19 11:08 docker-compose.yml
-rw-r--r-- 1 root    root     13 Sep 19 09:14 greet-docker-root.txt
-rw-rw-r-- 1 vagrant vagrant  13 Sep 19 11:11 greet-docker.txt
-rw-rw-r-- 1 vagrant vagrant  13 Sep 19 09:10 greet.txt

今回のやり方では、コンテナのプラットフォーム間でのポータビリティは低下するというデメリットがある。 もし、プラットフォーム間のポータビリティも落としたくない場合は、ENTRYPOINT のスクリプトでユーザを編集する必要があるだろう。 とはいえ、そこを割り切って手っ取り早く解決する方法の一つとしては使い所を選べばありだと感じた。

参考

qiita.com

リモートサーバ上の Docker コンテナに X11 Forwarding する

リモートにあるサーバで動かしている Docker コンテナ上の X アプリケーションの GUI をローカルのマシンから確認したいと思った。 そこで、Docker コンテナとローカルマシンの間で X11 Forwarding してみることにした。 やってみると意外と手間取ったので記録として残しておく。

まず、リモートの Docker ホスト (Docker コンテナを動かすマシン) の環境は次の通り。

$ cat /etc/lsb-release
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.3 LTS"
$ uname -r
4.15.0-62-generic

そして、ローカルのマシン環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G95

ローカルの環境には、あらかじめ XQuartz (macOS 向けの X Window System 実装) をインストールしておく。

$ brew cask install xquartz

今回進める作業の手順としては、次の通り。

  1. リモートのマシン上で SSH でログインできる Docker コンテナを動かす
  2. ローカルのマシンから Docker コンテナに X11 Forwarding を有効にして SSH でログインする
  3. Docker コンテナ上で X アプリケーションを実行する
  4. 実行結果をローカルマシン上で確認する

リモートのマシンに Docker をインストールする

まずは最初の手順としてリモートのマシンに Docker をインストールする。

APT のリポジトリを更新した上で、もし既にインストールされている Docker コンポーネントがあればアンインストールしておく。

$ sudo apt update
$ sudo apt -y remove docker docker-engine docker.io containerd runc

続いて、Docker 関連のコンポーネントをインストールするのに必要なパッケージをインストールする。

$ sudo apt -y install \
    apt-transport-https \
    ca-certificates \
    curl \
    gnupg-agent \
    software-properties-common \
    python3-pip

APT に Docker のリポジトリを登録する。

$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
$ sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"

Docker 本体をインストールする。

$ sudo apt update
$ sudo apt -y install docker-ce docker-ce-cli containerd.io

次のように docker version コマンドでクライアントとサーバの両方が正しく出力されれば上手くいっている。

$ sudo docker version
Client: Docker Engine - Community
 Version:           19.03.2
 API version:       1.40
 Go version:        go1.12.8
 Git commit:        6a30dfc
 Built:             Thu Aug 29 05:29:11 2019
 OS/Arch:           linux/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          19.03.2
  API version:      1.40 (minimum version 1.12)
  Go version:       go1.12.8
  Git commit:       6a30dfc
  Built:            Thu Aug 29 05:27:45 2019
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.2.6
  GitCommit:        894b81a4b802e4eb2a91d1ce216b8817763c29fb
 runc:
  Version:          1.0.0-rc8
  GitCommit:        425e105d5a03fabd737a126ad93d62a9eeede87f
 docker-init:
  Version:          0.18.0
  GitCommit:        fec3683

続いて Docker Compose をインストールする。

$ sudo pip3 install docker-compose

こちらも、次のようにバージョン情報が出れば大丈夫。

$ docker-compose version
docker-compose version 1.24.1, build 4667896
docker-py version: 3.7.3
CPython version: 3.6.8
OpenSSL version: OpenSSL 1.1.1  11 Sep 2018

X11 Forwarding が有効な SSH ログインできる Docker イメージをビルドする

Docker がインストールできたら、次はコンテナのイメージを作る。

汎用性をもたせるためにプロセスは Supervisord 経由で起動するため以下の通り設定ファイルを用意する。

$ cat << 'EOF' > supervisord.conf
[supervisord]
nodaemon=true

[program:sshd]
command=/usr/sbin/sshd -D -ddd
user=root
stdout_logfile=/var/log/supervisor/sshd.log
redirect_stderr=true
EOF

コンテナが起動するプロセスとなるシェルスクリプトを用意する。 このスクリプトの中では SSH でログインするときのユーザのパスワード設定と Supervisord の起動を行っている。

$ cat << 'EOF' > docker-entrypoint.sh
#!/usr/bin/env bash

set -Ceux

: "Change user password" && {
  # check variable existence
  if [ ! -v PASSWORD_HASH ]; then
    echo "please define 'PASSWORD_HASH' environment variable"
    exit 1
  fi
  usermod -p "${PASSWORD_HASH}" ${USERNAME}
}

: "Start supervisord" && {
  supervisord -c /etc/supervisord.conf
}
EOF

続いて肝心の Docker イメージをビルドするための Dockerfile を用意する。 このイメージでは必要な設定を sshd に施すと共に、必要な関連パッケージをインストールしている。 ベースイメージは Docker ホストと同じ Ubuntu 18.04 LTS にした。

$ cat << 'EOF' > Dockerfile
FROM ubuntu:18.04

# Non administrative username (SSH login user)
ENV USERNAME=example

# Use mirror repository
RUN sed -i.bak -e "s%http://[^ ]\+%mirror://mirrors.ubuntu.com/mirrors.txt%g" /etc/apt/sources.list

# Install prerequisite packages
RUN apt update \
 && apt -yq dist-upgrade \
 && apt install -yq --no-install-recommends \
      sudo \
      xserver-xorg \
      openssh-server \
      supervisor \
      xauth \
      x11-apps

# Prepare OpenSSH server
RUN mkdir /var/run/sshd \
 && sed -i -e "s/^#AddressFamily.*$/AddressFamily inet/" /etc/ssh/sshd_config

# Setup non administrative user
RUN useradd -m ${USERNAME} \
 && usermod -aG sudo ${USERNAME}

# Expose container ports
EXPOSE 22

# Copy settings
COPY supervisord.conf /etc
COPY docker-entrypoint.sh /var/tmp

# Boot process
CMD bash /var/tmp/docker-entrypoint.sh
EOF

イメージとコンテナを管理するために Docker Compose 用の設定ファイルも用意しておこう。 この設定ではコンテナに SSH でログインする用の TCP/22 ポートを、Docker ホストの 127.0.0.1:2222 にマッピングすることになる。 意図的にループバックアドレスに bind するのは、下手にインターネットから到達できないようにするため。

$ cat << 'EOF' > docker-compose.yml
version: "3"
services:
  server:
    build:
      context: .
    environment:
      - PASSWORD_HASH
    image: example/sshd-container
    ports:
      - "127.0.0.1:2222:22"
EOF

Docker Compose 経由でイメージをビルドする。

$ sudo docker-compose build

上手くいけば次のようにイメージが登録される。

$ sudo docker images
REPOSITORY               TAG                 IMAGE ID            CREATED             SIZE
example/sshd-container   latest              0c9b2af9011d        19 minutes ago      318MB
<none>                   <none>              df1f957680d7        20 minutes ago      318MB
ubuntu                   18.04               a2a15febcdf3        4 weeks ago         64.2MB

コンテナを起動する

先ほど docker-entrypoint.sh の中でチェックしていた通り、コンテナの起動には SSH ログイン用のパスワードハッシュが必要になる。 例えば次のように Python などを使って生成しておく。

$ python3 -c "import crypt; print(crypt.crypt('mypasswd', crypt.METHOD_SHA512))"
$6$ADfMjPqRhaGQ4r80$Pc4n29qZmdRFMJBGK1FghsmWWQZOqgjom8ia2SA5r9HeCnhASKUoToq30Q5eCh3272/UvvaVfGa0IIpGyOwbK1

パスワードをターミナルの履歴に残したくないときは次のように getpass.getpass() を使うと良いかも。 あるいは、もっとちゃんとするならパスワード認証ではなく公開鍵を仕込むようにした方が良いと思う。

$ python3 -c "import crypt; from getpass import getpass; print(crypt.crypt(getpass(), crypt.METHOD_SHA512))"

生成したパスワードのハッシュを環境変数ごしに渡しつつコンテナを起動する。

$ export PASSWORD_HASH=$(python3 -c "import crypt; print(crypt.crypt('mypasswd', crypt.METHOD_SHA512))")
$ sudo docker-compose run \
    --name sshd-container \
    --rm \
    -e PASSWORD_HASH=${PASSWORD_HASH} \
    --service-ports \
    server

これでコンテナに SSH でログインするためのポートがリモートマシンの 127.0.0.1:2222 にマッピングされる。

$ ss -tlnp | grep 2222
LISTEN   0         128               127.0.0.1:2222             0.0.0.0:*

X11 Forwarding が有効な状態で SSH ログインする

ここまでの工程でコンテナにアクセスするためのポートは開いた。 ただし、bind されているアドレスが Docker ホストのループバックアドレスになっている。 セキュリティを考えてのことだけど、このままだとローカルのマシンからも疎通がない。 この点は SSH の Port Forwarding で解決する。

リモートの 2222 ポートを、ローカルの 22222 ポートに Port Forwarding で引き出してくる。 例えば今回は Vagrant を使ってリモートサーバを構築してあるので、次のようにする。 新しいターミナルを開いて Port Forwarding しよう。

$ vagrant ssh-config > ssh.config
$ ssh -F ssh.config \
      -L 22222:localhost:2222 \
      default

これでリモートの 2222 ポートが、ローカルの 22222 越しにアクセスできるようになった。

$ lsof -i -P | grep -i listen | grep 22222 
ssh       35299 amedama    5u  IPv6 0x9d2c7f5e707aebd5      0t0  TCP localhost:22222 (LISTEN)
ssh       35299 amedama    6u  IPv4 0x9d2c7f5e74def915      0t0  TCP localhost:22222 (LISTEN)

あとはローカルの TCP/22222 に向けて X11 Forwarding を有効にしつつ SSH でログインする。 OpenSSH の実装であれば -X をつければ X11 Forwarding が有効になる。

$ ssh -XC \
      -o StrictHostKeyChecking=no \
      -o UserKnownHostsFile=/dev/null \
      -p 22222 \
      example@localhost

ログインすると、ちゃんと DISPLAY 環境変数の内容もセットされている。

$ echo $DISPLAY
localhost:10.0

例えばサンプルとして xeyes を起動してみよう。

$ xeyes

見慣れた目玉が見えるようになるはず。

f:id:momijiame:20190912235905p:plain

もし上手くいかないときは、次のようにして Supervisord が起動した sshd のログを確認すると良い。

$ sudo docker exec -it sshd-container tail -f /var/log/supervisor/sshd.log

いじょう。

参考

blog.n-z.jp

Ubuntu 18.04 LTS の APT で高速なミラーリポジトリを使う

Vagrant や Docker のイメージの中には APT のリポジトリが決め打ちになっているものがある。 高速なミラーリポジトリが使えないとアップデートが遅くて作業に難儀することになる。 今回は、そんなときの解決方法について。

使った環境は次の通り。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.3 LTS"
$ uname -r
4.15.0-60-generic

ミラーリポジトリを使えるようにする

例えば、初期状態だと次のようになっている場合がある。

$ cat /etc/apt/sources.list | sed -e "/^#/d" -e "/^$/d"
deb http://archive.ubuntu.com/ubuntu bionic main restricted
deb http://archive.ubuntu.com/ubuntu bionic-updates main restricted
deb http://archive.ubuntu.com/ubuntu bionic universe
deb http://archive.ubuntu.com/ubuntu bionic-updates universe
deb http://archive.ubuntu.com/ubuntu bionic multiverse
deb http://archive.ubuntu.com/ubuntu bionic-updates multiverse
deb http://archive.ubuntu.com/ubuntu bionic-backports main restricted universe multiverse
deb http://security.ubuntu.com/ubuntu bionic-security main restricted
deb http://security.ubuntu.com/ubuntu bionic-security universe
deb http://security.ubuntu.com/ubuntu bionic-security multiverse

現行の APT はリポジトリの URL を mirror://mirrors.ubuntu.com/mirrors.txt に指定することでミラーが使えるようになるらしい。 試しに上記を sed コマンドで置換してみよう。

$ sudo sed -i.bak -e 's%http://[^ ]\+%mirror://mirrors.ubuntu.com/mirrors.txt%g' /etc/apt/sources.list

実行結果はこんな感じ。 なお、元のファイルも .bak という名前で残されているので失敗しても元に戻せる。

$ cat /etc/apt/sources.list | sed -e "/^#/d" -e "/^$/d"
deb mirror://mirrors.ubuntu.com/mirrors.txt bionic main restricted
deb mirror://mirrors.ubuntu.com/mirrors.txt bionic-updates main restricted
deb mirror://mirrors.ubuntu.com/mirrors.txt bionic universe
deb mirror://mirrors.ubuntu.com/mirrors.txt bionic-updates universe
deb mirror://mirrors.ubuntu.com/mirrors.txt bionic multiverse
deb mirror://mirrors.ubuntu.com/mirrors.txt bionic-updates multiverse
deb mirror://mirrors.ubuntu.com/mirrors.txt bionic-backports main restricted universe multiverse
deb mirror://mirrors.ubuntu.com/mirrors.txt bionic-security main restricted
deb mirror://mirrors.ubuntu.com/mirrors.txt bionic-security universe
deb mirror://mirrors.ubuntu.com/mirrors.txt bionic-security multiverse

更新してみるとネットワーク的に近場のミラーリポジトリが使われるようになるはず。

$ sudo apt update
$ sudo apt upgrade -y

ちなみに、上記のテクニックは Ubuntu 18.04 LTS (Bionic) だけでなく Ubuntu 10.04 (Lucid) 以降で使えるらしい。 めでたしめでたし。

Python: Keras で AutoEncoder を書いてみる

今回はニューラルネットワークのフレームワークの Keras を使って AutoEncoder を書いてみる。 AutoEncoder は入力になるべく近い出力をするように学習したネットワークをいう。 AutoEncoder は特徴量の次元圧縮や異常検知など、幅広い用途に用いられている。

使った環境は次の通り。

$ sw_vers        
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G95
$ python -V 
Python 3.7.4

下準備

まずは必要なパッケージをインストールしておく。

$ pip install keras tensorflow matplotlib

中間層が一層の AutoEncoder

Keras の Sequential API を使って実装した最も単純な AutoEncoder のサンプルコードを以下に示す。 データセットには MNIST を使った。 入力と出力が 28 x 28 = 784 次元なのに対し、中間層は一層で 36 次元しかない。 つまり、中間層では次元圧縮に相当する処理をしている。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import numpy as np
from keras import layers
from keras import models
from keras import callbacks
from keras.datasets import mnist
from matplotlib import pyplot as plt
from matplotlib import cm

def main():
    # MNIST データセットを読み込む
    (x_train, train), (x_test, y_test) = mnist.load_data()
    image_height, image_width = 28, 28
    # 中間層で圧縮される次元数
    encoding_dim = 36  # 中間層の出力を 6 x 6 の画像として可視化するため

    # Flatten
    x_train = x_train.reshape(x_train.shape[0], image_height * image_width)
    x_test = x_test.reshape(x_test.shape[0], image_height * image_width)

    # Min-Max Normalization
    x_train = x_train.astype('float32')
    x_test = x_test.astype('float32')
    x_train = (x_train - x_train.min()) / (x_train.max() - x_train.min())
    x_test = (x_test - x_test.min()) / (x_test.max() - x_test.min())

    # 中間層が一層だけの単純な AutoEncoder
    model = models.Sequential()
    model.add(layers.Dense(encoding_dim, activation='relu',
                           input_shape=(image_height * image_width,)))
    model.add(layers.Dense(image_height * image_width,
                           activation='sigmoid'))

    # モデルの構造を確認する
    print(model.summary())

    model.compile(optimizer='adam',
                  loss='binary_crossentropy')

    fit_callbacs = [
        callbacks.EarlyStopping(monitor='val_loss',
                                patience=5,
                                mode='min')
    ]

    # モデルを学習させる
    model.fit(x_train, x_train,
              epochs=100,
              batch_size=256,
              shuffle=True,
              validation_data=(x_test, x_test),
              callbacks=fit_callbacs,
              )

    # テストデータの損失を確認しておく
    score = model.evaluate(x_test, x_test, verbose=0)
    print('test xentropy:', score)

    # 学習済みのモデルを元に、次元圧縮だけするモデルを用意する
    encoder = models.clone_model(model)
    encoder.compile(optimizer='adam',
                    loss='binary_crossentropy')
    encoder.set_weights(model.get_weights())
    # 最終段のレイヤーを取り除く
    encoder.pop()

    # テストデータからランダムに 10 点を選び出す
    p = np.random.random_integers(0, len(x_test), 10)
    x_test_sampled = x_test[p]
    # 選びだしたサンプルを AutoEncoder にかける
    x_test_sampled_pred = model.predict_proba(x_test_sampled,
                                              verbose=0)
    # 次元圧縮だけする場合
    x_test_sampled_enc = encoder.predict_proba(x_test_sampled,
                                               verbose=0)

    # 処理結果を可視化する
    fig, axes = plt.subplots(3, 10)
    for i, label in enumerate(y_test[p]):
        # 元画像を上段に表示する
        img = x_test_sampled[i].reshape(image_height, image_width)
        axes[0][i].imshow(img, cmap=cm.gray_r)
        axes[0][i].axis('off')
        axes[0][i].set_title(label, color='red')
        # AutoEncoder で次元圧縮した画像を下段に表示する
        enc_img = x_test_sampled_enc[i].reshape(6, 6)
        axes[1][i].imshow(enc_img, cmap=cm.gray_r)
        axes[1][i].axis('off')
        # AutoEncoder で復元した画像を下段に表示する
        pred_img = x_test_sampled_pred[i].reshape(image_height, image_width)
        axes[2][i].imshow(pred_img, cmap=cm.gray_r)
        axes[2][i].axis('off')

    plt.show()


if __name__ == '__main__':
    main()

上記を実行してみよう。 検証用データに対する損失は約 0.087 だった。

$ python ae.py
...(snip)...
Model: "sequential_1"
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
dense_1 (Dense)              (None, 36)                28260
_________________________________________________________________
dense_2 (Dense)              (None, 784)               29008
=================================================================
Total params: 57,268
Trainable params: 57,268
Non-trainable params: 0
_________________________________________________________________
...(snip)...
test xentropy: 0.08722344622612

同時に、以下のグラフが得られる。 上段が入力画像、中段が AutoEncoder の中間層の出力、下段が復元された出力画像になっている。

f:id:momijiame:20190908213044p:plain

上記を見ると多少ボケたりかすれたりはしているものの、ちゃんと入力に近い画像が出力されていることがわかる。 中間層の出力は人間にはよくわからないけど、これでちゃんと元の画像に近いものが復元できるのはなんとも不思議な感じ。

中間層が 5 層の AutoEncoder

試しに先ほどのネットワークに中間層を足して、キャパシティを上げてみよう。 以下のサンプルコードでは次元を 784 -> 128 -> 64 -> 36 -> 64 -> 128 -> 784 と変化させている。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import numpy as np
from keras import layers
from keras import models
from keras import callbacks
from keras.datasets import mnist
from matplotlib import pyplot as plt
from matplotlib import cm

def main():
    # MNIST データセットを読み込む
    (x_train, train), (x_test, y_test) = mnist.load_data()
    image_height, image_width = 28, 28
    # 中間層で圧縮される次元数
    encoding_dim = 36  # 6 x 6 の画像として可視化してみるため

    # Flatten
    x_train = x_train.reshape(x_train.shape[0], image_height * image_width)
    x_test = x_test.reshape(x_test.shape[0], image_height * image_width)

    # Min-Max Normalization
    x_train = x_train.astype('float32')
    x_test = x_test.astype('float32')
    x_train = (x_train - x_train.min()) / (x_train.max() - x_train.min())
    x_test = (x_test - x_test.min()) / (x_test.max() - x_test.min())

    # 中間層を 4 層まで増やしたネットワーク
    model = models.Sequential()
    model.add(layers.Dense(128, activation='relu',
                           input_shape=(image_height * image_width,)))
    model.add(layers.Dense(64, activation='relu'))
    model.add(layers.Dense(encoding_dim, activation='relu'))
    model.add(layers.Dense(64, activation='relu'))
    model.add(layers.Dense(128, activation='relu'))
    model.add(layers.Dense(image_height * image_width,
                           activation='sigmoid'))

    # モデルの構造を確認する
    print(model.summary())

    model.compile(optimizer='adam',
                  loss='binary_crossentropy')

    print(model.summary())

    fit_callbacs = [
        callbacks.EarlyStopping(monitor='val_loss',
                                patience=5,
                                mode='min')
    ]

    # モデルを学習させる
    model.fit(x_train, x_train,
              epochs=100,
              batch_size=256,
              shuffle=True,
              validation_data=(x_test, x_test),
              callbacks=fit_callbacs,
              )

    # テストデータの損失を確認しておく
    score = model.evaluate(x_test, x_test, verbose=0)
    print('test xentropy:', score)

    # 学習済みのモデルを元に、次元圧縮だけするモデルを用意する
    encoder = models.clone_model(model)
    encoder.compile(optimizer='adam',
                    loss='binary_crossentropy')
    encoder.set_weights(model.get_weights())
    # 中間層までのレイヤーを取り除く
    encoder.pop()
    encoder.pop()
    encoder.pop()

    # テストデータからランダムに 10 点を選び出す
    p = np.random.random_integers(0, len(x_test), 10)
    x_test_sampled = x_test[p]
    # 選びだしたサンプルを AutoEncoder にかける
    x_test_sampled_pred = model.predict_proba(x_test_sampled,
                                              verbose=0)
    # 次元圧縮だけする場合
    x_test_sampled_enc = encoder.predict_proba(x_test_sampled,
                                               verbose=0)

    # 処理結果を可視化する
    fig, axes = plt.subplots(3, 10)
    for i, label in enumerate(y_test[p]):
        # 元画像を上段に表示する
        img = x_test_sampled[i].reshape(image_height, image_width)
        axes[0][i].imshow(img, cmap=cm.gray_r)
        axes[0][i].axis('off')
        axes[0][i].set_title(label, color='red')
        # AutoEncoder で次元圧縮した画像を中段に表示する
        enc_img = x_test_sampled_enc[i].reshape(6, 6)
        axes[1][i].imshow(enc_img, cmap=cm.gray_r)
        axes[1][i].axis('off')
        # AutoEncoder で復元した画像を下段に表示する
        pred_img = x_test_sampled_pred[i].reshape(image_height, image_width)
        axes[2][i].imshow(pred_img, cmap=cm.gray_r)
        axes[2][i].axis('off')

    plt.show()


if __name__ == '__main__':
    main()

上記を実行してみる。 テストデータの損失は先ほどより減って約 0.079 となった。

$ python ae.py
...(snip)...
Layer (type)                 Output Shape              Param #   
=================================================================
dense_1 (Dense)              (None, 128)               100480    
_________________________________________________________________
dense_2 (Dense)              (None, 64)                8256      
_________________________________________________________________
dense_3 (Dense)              (None, 36)                2340      
_________________________________________________________________
dense_4 (Dense)              (None, 64)                2368      
_________________________________________________________________
dense_5 (Dense)              (None, 128)               8320      
_________________________________________________________________
dense_6 (Dense)              (None, 784)               101136    
=================================================================
Total params: 222,900
Trainable params: 222,900
Non-trainable params: 0
_________________________________________________________________
...(snip)...
test xentropy: 0.07924877260923385

出力されたグラフは次の通り。 今度の中間層の出力は、先ほどよりもパターンが出ているような気がする。 例えば、左上と右下にどの画像でも白くなっている出力があったりするようだ。

f:id:momijiame:20190908213602p:plain

そんなかんじで。

PythonとKerasによるディープラーニング

PythonとKerasによるディープラーニング

Python: pandas のデータ型をキャストしてメモリを節約してみる

pandas の DataFrame は明示的にデータ型を指定しないと整数型や浮動小数点型のカラムを 64 ビットで表現する。 pandas の DataFrame は、表現に使うビット数が大きいと、メモリ上のオブジェクトのサイズも当然ながら大きくなる。 そこで、今回は DataFrame の各カラムに含まれる値を調べながら、より小さなビット数の表現にキャストすることでメモリの使用量を節約してみる。 なお、ネットを調べると既に同じような実装が見つかったけど、自分でスクラッチしてみた。

今回使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G95
$ python -V          
Python 3.7.4
$ pip list | grep -i pandas               
pandas          0.25.1 

下準備

まずは必要となるパッケージをインストールしておく。

$ pip install pandas tqdm seaborn

pandas のデータ型をキャストしてメモリを節約する

以下が DataFrame の各カラムのデータ型をキャストしてメモリを節約するサンプルコード。 例として 64 ビットで表現されているものの、実はもっと小さなビット数で表現できるデータを適用している。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from functools import partial
import logging

import numpy as np
import pandas as pd
from tqdm import tqdm


LOGGER = logging.getLogger(__name__)


def _fall_within_range(dtype_min_value, dtype_max_value, min_value, max_value):
    """データ型の表現できる範囲に収まっているか調べる関数"""
    if min_value < dtype_min_value:
        # 下限が越えている
        return False

    if max_value > dtype_max_value:
        # 上限が越えている
        return False

    # 範囲内に収まっている
    return True


def _cast(df, col_name, cast_candidates):
    # カラムに含まれる最小値と最大値を取り出す
    min_value, max_value = df[col_name].min(), df[col_name].max()

    for cast_type, (dtype_min_value, dtype_max_value) in cast_candidates.items():
        if df[col_name].dtype == cast_type:
            # 同じ型まで到達した時点で、キャストする意味はなくなる
            return

        if _fall_within_range(dtype_min_value, dtype_max_value, min_value, max_value):
            # キャストしたことをログに残す
            LOGGER.info(f'column {col_name} casted: {df[col_name].dtype.type} to {cast_type}')
            # 最も小さなビット数で表現できる型にキャストできたので終了
            df[col_name] = df[col_name].astype(cast_type)
            return


def _cast_func(df, col_name):
    col_type = df[col_name].dtype.type

    if issubclass(col_type, np.integer):
        # 整数型
        cast_candidates = {
            cast_type: (np.iinfo(cast_type).min, np.iinfo(cast_type).max)
            for cast_type in [np.int8, np.int16, np.int32]
        }
        return partial(_cast, cast_candidates=cast_candidates)

    if issubclass(col_type, np.floating):
        # 浮動小数点型
        cast_candidates = {
            cast_type: (np.finfo(cast_type).min, np.finfo(cast_type).max)
            for cast_type in [np.float16, np.float32]
        }
        return partial(_cast, cast_candidates=cast_candidates)

    # その他は未対応
    return None


def _memory_usage(df):
    """データフレームのサイズと接頭辞を返す"""
    units = ['B', 'kB', 'MB', 'GB']
    usage = float(df.memory_usage().sum())

    for unit in units:
        if usage < 1024:
            return usage, unit
        usage /= 1024

    return usage, unit


def shrink(df):
    # 元のサイズをログに記録しておく
    usage, unit = _memory_usage(df)
    LOGGER.info(f'original dataframe size: {usage:.0f}{unit}')

    for col_name in tqdm(df.columns):
        # 各カラムごとにより小さなビット数で表現できるか調べていく
        func = _cast_func(df, col_name)
        if func is None:
            continue
        func(df, col_name)

    # 事後のサイズをログに記録する
    usage, unit = _memory_usage(df)
    LOGGER.info(f'shrinked dataframe size: {usage:.0f}{unit}')


def main():
    log_fmt = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
    logging.basicConfig(format=log_fmt,
                        level=logging.DEBUG)

    data = [
        (2147483648, 32768, 129, 0, 2.0e+308, 65510.0, 0.0, 'foo'),
        (2147483649, 32769, 130, 1, 2.1e+308, 65520.0, 0.1, 'bar'),
    ]

    df = pd.DataFrame(data)
    print(df.dtypes)

    shrink(df)
    print(df.dtypes)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python shrink.py
0      int64
1      int64
2      int64
3      int64
4    float64
5    float64
6    float64
7     object
dtype: object
2019-09-05 22:32:45,726 - __main__ - INFO - original dataframe size: 256B
  0%|                                                     | 0/8 [00:00<?, ?it/s]2019-09-05 22:32:45,742 - __main__ - INFO - column 1 casted: <class 'numpy.int64'> to <class 'numpy.int32'>
2019-09-05 22:32:45,743 - __main__ - INFO - column 2 casted: <class 'numpy.int64'> to <class 'numpy.int16'>
2019-09-05 22:32:45,744 - __main__ - INFO - column 3 casted: <class 'numpy.int64'> to <class 'numpy.int8'>
2019-09-05 22:32:45,746 - __main__ - INFO - column 5 casted: <class 'numpy.float64'> to <class 'numpy.float32'>
2019-09-05 22:32:45,748 - __main__ - INFO - column 6 casted: <class 'numpy.float64'> to <class 'numpy.float16'>
100%|███████████████████████████████████████████| 8/8 [00:00<00:00, 1066.51it/s]
2019-09-05 22:32:45,750 - __main__ - INFO - shrinked dataframe size: 202B
0      int64
1      int32
2      int16
3       int8
4    float64
5    float32
6    float16
7     object
dtype: object

上記の実行結果を確認すると、元は 256B だったデータサイズが 202B まで小さくなっている。 また、各カラムのデータ型も表現できる最小のビット数まで小さくなっていることもわかる。

もうちょっとちゃんとしたデータに対しても適用してみることにしよう。 seaborn からロードできる diamonds データセットを使ってみることにした。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from functools import partial
import logging

import seaborn
import numpy as np
import pandas as pd
from tqdm import tqdm


LOGGER = logging.getLogger(__name__)


def _fall_within_range(dtype_min_value, dtype_max_value, min_value, max_value):
    """データ型の表現できる範囲に収まっているか調べる関数"""
    if min_value < dtype_min_value:
        # 下限が越えている
        return False

    if max_value > dtype_max_value:
        # 上限が越えている
        return False

    # 範囲内に収まっている
    return True


def _cast(df, col_name, cast_candidates):
    # カラムに含まれる最小値と最大値を取り出す
    min_value, max_value = df[col_name].min(), df[col_name].max()

    for cast_type, (dtype_min_value, dtype_max_value) in cast_candidates.items():
        if df[col_name].dtype == cast_type:
            # 同じ型まで到達した時点で、キャストする意味はなくなる
            return

        if _fall_within_range(dtype_min_value, dtype_max_value, min_value, max_value):
            # キャストしたことをログに残す
            LOGGER.info(f'column {col_name} casted: {df[col_name].dtype.type} to {cast_type}')
            # 最も小さなビット数で表現できる型にキャストできたので終了
            df[col_name] = df[col_name].astype(cast_type)
            return


def _cast_func(df, col_name):
    col_type = df[col_name].dtype.type

    if issubclass(col_type, np.integer):
        # 整数型
        cast_candidates = {
            cast_type: (np.iinfo(cast_type).min, np.iinfo(cast_type).max)
            for cast_type in [np.int8, np.int16, np.int32]
        }
        return partial(_cast, cast_candidates=cast_candidates)

    if issubclass(col_type, np.floating):
        # 浮動小数点型
        cast_candidates = {
            cast_type: (np.finfo(cast_type).min, np.finfo(cast_type).max)
            for cast_type in [np.float16, np.float32]
        }
        return partial(_cast, cast_candidates=cast_candidates)

    # その他は未対応
    return None


def _memory_usage(df):
    """データフレームのサイズと接頭辞を返す"""
    units = ['B', 'kB', 'MB', 'GB']
    usage = float(df.memory_usage().sum())

    for unit in units:
        if usage < 1024:
            return usage, unit
        usage /= 1024

    return usage, unit


def shrink(df):
    # 元のサイズをログに記録しておく
    usage, unit = _memory_usage(df)
    LOGGER.info(f'original dataframe size: {usage:.0f}{unit}')

    for col_name in tqdm(df.columns):
        # 各カラムごとにより小さなビット数で表現できるか調べていく
        func = _cast_func(df, col_name)
        if func is None:
            continue
        func(df, col_name)

    # 事後のサイズをログに記録する
    usage, unit = _memory_usage(df)
    LOGGER.info(f'shrinked dataframe size: {usage:.0f}{unit}')


def main():
    log_fmt = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
    logging.basicConfig(format=log_fmt,
                        level=logging.DEBUG)

    df = seaborn.load_dataset('diamonds')

    print(df.dtypes)
    shrink(df)
    print(df.dtypes)


if __name__ == '__main__':
    main()

上記を実行した結果が次の通り。 元の DataFrame が 4MB だったのに対し、キャストした後は 2MB と半減していることがわかる。

$ python shrink.py
carat      float64
cut         object
color       object
clarity     object
depth      float64
table      float64
price        int64
x          float64
y          float64
z          float64
dtype: object
2019-09-05 22:38:13,848 - __main__ - INFO - original dataframe size: 4MB
  0%|          | 0/10 [00:00<?, ?it/s]2019-09-05 22:38:13,854 - __main__ - INFO - column carat casted: <class 'numpy.float64'> to <class 'numpy.float16'>
2019-09-05 22:38:13,858 - __main__ - INFO - column depth casted: <class 'numpy.float64'> to <class 'numpy.float16'>
2019-09-05 22:38:13,860 - __main__ - INFO - column table casted: <class 'numpy.float64'> to <class 'numpy.float16'>
2019-09-05 22:38:13,863 - __main__ - INFO - column price casted: <class 'numpy.int64'> to <class 'numpy.int16'>
2019-09-05 22:38:13,865 - __main__ - INFO - column x casted: <class 'numpy.float64'> to <class 'numpy.float16'>
2019-09-05 22:38:13,868 - __main__ - INFO - column y casted: <class 'numpy.float64'> to <class 'numpy.float16'>
2019-09-05 22:38:13,870 - __main__ - INFO - column z casted: <class 'numpy.float64'> to <class 'numpy.float16'>
100%|██████████| 10/10 [00:00<00:00, 538.64it/s]
2019-09-05 22:38:13,873 - __main__ - INFO - shrinked dataframe size: 2MB
carat      float16
cut         object
color       object
clarity     object
depth      float16
table      float16
price        int16
x          float16
y          float16
z          float16
dtype: object

大きなデータをオンメモリで扱うときは使える場面があるかもしれない。

Python: LightGBM で学習済みモデルを自動で永続化するコールバックを書いてみた

ニューラルネットワークを実装するためのフレームワークの Keras は LightGBM と似たようなコールバックの機構を備えている。 そして、いくつか標準で用意されているコールバックがある。

keras.io

そんな中に ModelCheckpoint というコールバックがあって、これが意外と便利そうだった。 このコールバックは良いスコアを記録したモデルを自動的にディスクに永続化するためのもの。 そこで、今回は似たような機能のコールバックを LightGBM に移植してみることにした。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G95
$ python -V
Python 3.7.4

下準備

まずは今回使うパッケージをインストールしておく。

$ pip install lightgbm scikit-learn

自動でモデルを永続化してくれるコールバック

自動でモデルを保存してくれるコールバックを実装したサンプルコードが次の通り。 コールバックは ModelCheckpointCallback という名前のクラスとして実装している。 使い方はコメントを読むことで大体わかると思う。 チェックするメトリックのスコアが過去に記録されたものを上回ったときに、そのモデルをディスクに書き出す。 サンプルコードではコールバックによって保存された学習済みモデルを復元して、ホールドアウトしておいたデータを予測させている。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import pickle

import numpy as np
import lightgbm as lgb
from sklearn.metrics import accuracy_score
from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.model_selection import StratifiedKFold


class ModelCheckpointCallback(object):
    """モデルをディスクに永続化するためのコールバック"""

    def __init__(self, save_file, monitor_metric, pickle_protocol=2):
        # モデルを保存するファイルパス
        self._save_file = save_file
        # スコアを確認するメトリックの名前
        self._monitor_metric = monitor_metric
        # 永続化に使う pickle のプロトコル (デフォルトでは互換性優先)
        self._pickle_protocol = pickle_protocol
        self._best_score = None

    def _is_higher_score(self, metric_score, is_higher_better):
        if self._best_score is None:
            # 過去にスコアが記録されていなければ問答無用でベスト
            return True

        if is_higher_better:
            return metric_score < metric_score
        else:
            return metric_score > metric_score

    def _save_model(self, model):
        if isinstance(self._save_file, str):
            # 文字列ならファイルパスと仮定する
            with open(self._save_file, mode='wb') as fp:
                pickle.dump(model, fp,
                            protocol=self._pickle_protocol)
        else:
            # それ以外は File-like object と仮定する
            pickle.dump(model, self._save_file,
                        protocol=self._pickle_protocol)

    def __call__(self, env):
        evals = env.evaluation_result_list
        for _, name, score, is_higher_better, _ in evals:
            # チェックするメトリックを選別する
            if name != self._monitor_metric:
                continue
            # 対象のメトリックが見つかっても過去のスコアよりも性能が悪ければ何もしない
            if not self._is_higher_score(score, is_higher_better):
                return
            # ベストスコアならモデルを永続化する
            self._save_model(env.model)
            return
        # メトリックが見つからなかった
        raise ValueError('monitoring metric not found')


def accuracy(preds, data):
    """精度 (Accuracy) を計算する関数"""
    y_true = data.get_label()
    y_pred = np.where(preds > 0.5, 1, 0)
    acc = accuracy_score(y_true, y_pred)
    # name, result, is_higher_better
    return 'accuracy', acc, True


def main():
    # Iris データセットを読み込む
    dataset = datasets.load_breast_cancer()
    X, y = dataset.data, dataset.target

    # デモ用にデータセットを分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y,
                                                        shuffle=True,
                                                        random_state=42)

    # LightGBM 用のデータセット表現に直す
    lgb_train = lgb.Dataset(X_train, y_train)

    # XXX: lightgbm.engine._CVBooster を pickle で永続化できるようにする
    #      lightgbm.train() で学習するときは不要な処理
    def __getstate__(self):
        return self.__dict__.copy()
    setattr(lgb.engine._CVBooster, '__getstate__', __getstate__)
    def __setstate__(self, state):
        self.__dict__.update(state)
    setattr(lgb.engine._CVBooster, '__setstate__', __setstate__)

    # 学習済みモデルを取り出すためのコールバックを用意する
    model_filename = 'lgb-cvbooster-model.pkl'
    checkpoint_cb = ModelCheckpointCallback(save_file=model_filename,
                                            monitor_metric='binary_logloss')
    callbacks = [
        checkpoint_cb,
    ]

    # データセットを 5-Fold CV で学習する
    lgbm_params = {
        'objective': 'binary',
        'metric': 'binary_logloss',
    }
    folds = StratifiedKFold(n_splits=5,
                            shuffle=True,
                            random_state=42)
    cv_result = lgb.cv(lgbm_params,
                       lgb_train,
                       num_boost_round=1000,
                       early_stopping_rounds=10,
                       folds=folds,
                       seed=42,
                       feval=accuracy,
                       callbacks=callbacks,
                       verbose_eval=10,
                       )

    # CV の結果を出力する
    print('eval accuracy:', cv_result['accuracy-mean'][-1])

    # 学習が終わったらモデルはディスクに永続化されている
    with open(model_filename, mode='rb') as fp:
        restored_model = pickle.load(fp)

    # 復元したモデルを使って Hold-out したデータを推論する
    y_pred_proba_list = restored_model.predict(X_test,
                                               restored_model.best_iteration)
    y_pred_probas = np.mean(y_pred_proba_list, axis=0)
    y_pred = np.where(y_pred_probas > 0.5, 1, 0)
    acc = accuracy_score(y_test, y_pred)
    print('test accuracy:', acc)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python lgbcpcb.py
...(snip)...
eval accuracy: 0.9508305647840531
test accuracy: 0.958041958041958

ちゃんとテスト用にホールドアウトしておいたデータを予測できていることがわかる。

なお、ファイルシステムを確認するとカレントディレクトリにモデルが直列化されたファイルがあるはず。

$ file lgb-cvbooster-model.pkl
lgb-cvbooster-model.pkl: data

めでたしめでたし。