アプリとサービスのすすめ

アプリやIT系のサービスを中心に書いていきます。たまに副業やビジネス関係の情報なども気ままにつづります

GKE上のkubernetesで機械学習運用環境(MLops)を作成手順・コマンド・知識メモ

機械学習運用環境(MLops)の一部を話題のkubernetes(k8s)で作成してみた。GCPのGKE上でk8sを利用できるので、今回はGKE上で途中まで構築。

今回参考にするMLopsは下の図。どうもこのサイトによるとメルカリでマイクロサービスとして運用されてるらしい  




このMLopsのk8sの部分だけ(下図)作成してみた。




・作成手順
step1. persistentVolumeを使ってAWSのS3とマウントしたPodをLoad balancerを使って、負荷分散を行う。http://[EXTERNAL-IP]にアクセスして、flaskで’hello, world’を表示させる。


step2.redisインスタンスと結合させたflaskアプリケーション用Podをgoogleチュートリアルに従い、作成


step3.上の図のPersistentVolumeと繋がってるtensorflowの部分の「worker用Pod」を作成。今回はceleryというライブラリのチュートリアルに従い、celeryでworker用Podを作成(途中まで)



途中からAmazon sageMakerの方がMLopsを簡単に作れるという噂で、k8sでの構築はstep3で中断。けど、せっかくなのでアウトプットしたログを記事にまとめておこうと思う。

構築に使用したコードとかファイルはgithubにあげてあリます。

目次
・MLopsの構築

kubernetes、Docker関係知識まとめ

・コマンド関連まとめ



MLopsの構築

step1. flask用Podを作成して、で’hello world'を表示

まずflaskアプリケーションで’hello world’を表示させてみる。flaskはDjangoより便利ってことで使った。


・用意したファイル
app.yaml 、main.py(flaskアプリ本体) 、requirements.txt(インストール用のライブラリ)、Dockerfile、flask_pod.yaml(Pod作成用マニフェストファイル)、sample_lb.yaml(load balancer用ファイル)


プロジェクト ID:sturdy-willow-167902


1. Dockerイメージpush & 確認

まずGCPのホーム画面からcloud shell(コンソール)を開いて(右上のとこにある)、kubernetes Engineを選択。 

cloud shell(コンソール)の起動方法は、GCPメイン画面から[プロジェクト]をクリック。

上段の右から6番目のアイコン「cloud shellにアクセス」をクリックし、「起動」で開ける。





以下コマンドはコンソール上で実行。

# Dockerイメージ作成して、Container Repositoriesにpush
$ export PROJECT_ID="$(gcloud config get-value project -q)”
$ docker build -t gcr.io/${PROJECT_ID}/visit-counter:v1 .
$ gcloud docker -- push gcr.io/${PROJECT_ID}/visit-counter:v1

#コンソール右上の [ウェブでプレビュー]で ’hello world’ が表示されるか確認
$ docker run --rm -p 8080:8080 gcr.io/${PROJECT_ID}/visit-counter:v1

→ Container Repositoriesに「visit-counter」ができる



2.Podの作成とserviceの定義


ここは単純にkubektlコマンドでPodの作成とサービスの定義(負荷分散)だけ。

そのためにGUIでもCUIでもいいので、kubernetes Engineのクラスタを作成しておく。クラスタを作成したら、コンソールで以下コマンド実行。

# クラスタ操作権限取得
$ gcloud container clusters get-credentials <クラスタ名> --zone=asia-northeast1-a

# 一応、VMインスタンス確認
$ gcloud compute instances list


***注意、マニフェストファイル(flask_pod.yaml)ではイメージ名のプロジェクトIDを展開する参考サイト

asia.gcr.io/$PROJECT_ID/wdpress/hello-world
# $PROJECT_ID は展開する必要があ流ので、上のイメージ名を下のように修正
asia.gcr.io/sturdy-willow-167902/wdpress-123456/hello-world

# Podの作成
$ kubectl apply -f flask_pod.yaml (pod確認  $ kubectl get pod)
# Serviceの定義
$ kubectl apply -f sample_lb.yaml
# EXTERNAL-IP(IPアドレス)の確認
$ kubectl get service

→ http://[EXTERNAL-IP] にブラウザからアクセスして、’hello world’が表示される。



step2.redisインスタンスと結合したflask用Pod作成

redisインスタンスと結合したflaskアプリケーションのPodを作成。
作成するMLopsではこの部分

これはGoogleのチュートリアルがあるが、素でやってもすんなり行かないので、ちょっと修正した。あとはチュートリアル通りに実行。 


チュートリアルで修正した点
インスタンスクラスタのリージョンを同じにする => region=asia-east1

・RESERVED_IP_RANGE の部分を、実際のインスタンスの予約済み IP 範囲に置き換える(参考サイト

マニフェストファイル(YAMファイル)のイメージ名のプロジェクトIDを展開



この3点だけ修正。あとはチュートリアル通りに実行。

プロジェクトID:sturdy-willow-167902



1.redisインスタンス作成

$ gcloud config set core/project sturdy-willow-167902
$ gcloud redis instances create myinstance --size=2 --region=asia-east1

# インスタンス情報確認
$ gcloud redis instances describe myinstance --region=asia-east1

>> authorizedNetwork: projects/sturdy-willow-167902/global/networks/default
createTime: '2018-12-05T02:19:31.887986552Z'
currentLocationId: asia-east1-a
host: 10.0.0.3
locationId: asia-east1-a
memorySizeGb: 2
name: projects/sturdy-willow-167902/locations/asia-east1/instances/myinstance
port: 6379
redisVersion: REDIS_3_2
reservedIpRange: 10.0.0.0/29
state: READY
tier: BASICtier: BASIC

# redisインスタンスを削除するとき
$ gcloud redis instances delete myinstance ―region=asia-east1




2.CUIクラスタ作成

$ gcloud config set core/project sturdy-willow-167902
$ gcloud config set compute/zone asia-east1
$ gcloud container clusters create visitcount-cluster --num-nodes=3 --enable-ip-alias

# クラスタの権限get
$ gcloud container clusters get-credentials visitcount-cluster --zone asia-east1 ―project sturdy-willow-167902





3.RESERVED_IP_RANGE の部分を、実際のインスタンスの予約済み IP 範囲に置き換え

$ git clone https://github.com/bowei/k8s-custom-iptables.git
$ cd k8s-custom-iptables/
$ TARGETS="10.0.0.0/29 192.168.0.0/16" ./install.sh
$ cd ..





4.Dockerイメージ作成とpush

$ export PROJECT_ID="$(gcloud config get-value project -q)”
$ docker build -t gcr.io/${PROJECT_ID}/visit-counter:v1 .
$ gcloud docker -- push gcr.io/${PROJECT_ID}/visit-counter:v1




5. configmapの作成

$ kubectl create configmap redishost ―from-literal=REDISHOST=10.0.0.3
$ kubectl get configmaps redishost -o yaml



6.pod作成, service定義、アクセス

$ kubectl apply -f visit-counter.yaml
$ kubectl get service visit-counter

# アクセスして動作確認
$ curl http://104.199.211.24 (http://[EXTERNAL-IP])


# configmap削除
$ kubectl delete configmap redishost



Step3.celeryでworker用のPodを作成

ここでは下図のPersistentVolumeとつながるworkerのPodを作成。

今回は一応、簡易版てことでceleryというライブラリのチュートリアルのコードを入れたPodを作成する、予定だった。 

ところがamazon sagemakerというのが便利との噂を聞いて、k8sからamazon sagemakerに切り替えてMLopsを構築した。ので、ここのworker用のPod作成は途中で中断。

worker用のPodは、step.1と2のmain.pyをceleryで実装したflaskアプリに書き換えて、celeryをインストールするよう改良。dockerfileからいつも通りPodを作ればいいかと思う。

step2のredisインスタンスのURL(http://104.199.211.24)をceleryCELERY_BROKER_URLCELERY_RESULT_BACKENDの部分に入れる。

from flask import Flask
flask_app = Flask(__name__)
flask_app.config.update(
    CELERY_BROKER_URL='redis://localhost:6379',
    CELERY_RESULT_BACKEND='redis://localhost:6379'
)
celery = make_celery(flask_app)


最終的に作ったPodが

$ Kubectl get pod 

で確認したとき、「Running」になってればいいと思う。

ちなみに、Amazon sageMakerでのMLops構築手順は別記事にまとめた。

trafalbad.hatenadiary.jp




kubernetes、Docker関係知識まとめ

PersistentVolumeについて

k8sのpersistentvolume (以下PV)はPodとは独立した外部ストレージ。

通常のk8sのPVはクラスタの外部の別の場所に作成するが、GCP上ではPVは代替ストレージの「GCEPesistentDisk」がすでに存在してる。PVCで用途に応じて容量・タイプなどを適宜追加・拡張する。




・PVのアクセスモード

  • > ReadWriteOnce: 1つのノードからR/Wでマウントできる
  • > ReadOnlyMany: 複数のノードからReadOnlyでマウントできる
  • > ReadWriteMany: 複数のノードからR/Wでマウントできる


・PVCの作成時、PVのストレージタイプの「標準」か「SSD」をStorageClassで指定

SSD」はマニフェストファイルではstorageClassName: ssdで指定。デフォルトは「標準」



app.yamlPython ランタイムについて

=>アプリケーションのコードと依存関係をインストールして実行するソフトウェアスタック。
標準ランタイムは、app.yaml で以下のように宣言(Docker でビルド)
runtime: python
env: flex


=>アプリケーションの app.yaml で使用するバージョン(Python2 or 3)をruntime_config に指定

runtime: python
env: flex
runtime_config:
    python_version: 3

=>ライブラリのインストール
同じディレクトリ内の requirements.txt ファイルを検索し、pipでrequirements.txt 内のライブラリを全てインストールする。






・PV<=> PVC <=> Pod間の結合関係
PVとPVCとPod間のどこが結合してるか?の関係について、


PV:metadata.name: nfs001, storageClassNameフィールドのslow

PVC:metadata.name: nfs-claim1, storageClassNameフィールドのslow

Pod:metadata.name: my-nginx-pod, persistentVolumeClaim.claimNameフィールドのnfs-claim1

つまりPVとPod、PVCとPodの結合関係は
PVとPod: slow(PV) <=> slow(Pod)
PVCとPod:nfs-claim1(PVC)<=> nfs-claim1(Pod)



Nginx => フリー&オープンソースなWebサーバのdockerイメージ




apiVersion=>簡単にいうと、「fieldや構成を定義しやすくするもの」らしい




DockerfileのENVの環境変数について
ENV命令は、環境変数と値のセット。値はDockerfile から派生する全てのコマンド環境で利用でき、 上書き、変数扱いも可能。

ex: 環境変数に値をセットする場合、

ENV MES Good-bye 

は MES=Good-bye
を意味する。 $MESで環境変数をあらわせる。




・gunicorn
例えば myapp.py というファイル内に app という名前でFlaskアプリがある場合、以下のように gunicorn で起動。

$ gunicorn myapp:app

デフォルトのPORTとHOSTは127.0.0.1と8000



gunicornのportについて
This will start one process running one thread listening on 127.0.0.1:8000.

I wish to run gunicorn on port 80.
→ gunicorn -b 0.0.0.0:80 backend:app




・flask

if __name__ == ‘__main__':
   app.run(debug=False, host='0.0.0.0', port=80)

host='0.0.0.0' の指定が大事。これがなければ、外部からアクセスすることができない。参考サイト



・os.environ.get
環境変数 key が存在すればその値を返し、存在しなければ default を返す。
key、default、および返り値は文字列。
もし環境変数がなくともKeyErrorがraiseされず、第二引数(キーワード引数)のdefaultの値が返る。(指定しなければNoneが返る)



・PORT、HOST
REDISHOST→ネットワークに接続されたコンピュータのこと(redisインスタンス自体)
REDISPORT→redisインスタンスに接続するドア(番号)




・configMap
# マニフェストファイル内での部分

env:
     - name: REDISHOST
        valueFrom:
          configMapKeyRef:
            name: redishost
            key: REDISHOST

keyのREDISHOSTが環境変数

# configmap作成コマンド
$ kubectl create configmap <map-name> <data-source>

# 例
$ kubectl create configmap redishost ―from-literal=REDISHOST=10.0.0.3

redishostがmap-nameで、それ以下がdata-source

上のコマンドで環境変数のREDISHOSTが10.0.0.3に変更された。

REDISHOST=redisインスタンス自体を環境変数に代入。Dockerfileのデフォルトのクラスタ環境変数をredisインスタンスのPORT(自体)で上書き。




コマンド関連まとめ



(ターミナル上での)k8sクラスタ作成コマンド

$ gcloud container clusters create <クラスタ名> --machine-type=n1-standard-1 ―num-nodes=3

ex: gcloud container clusters create mlops --machine-type=n1-standard-1 --num-nodes=3



クラスタ環境設定の権限取得(cloud shell上で作成したクラスタを操作できる)

$ gcloud container clusters get-credentials <名前> ―zone=<ゾーン>

ex:$ gcloud container clusters get-credentials mlopss --zone=asia-northeast1-a



kubectlコマンドでクラスタ内のノードとpod関係の操作

$ kubectl get nodes (ノードの確認)
$ kubectl get pod  (Podの確認)
# Serviceの情報を確認(クラスタ内で通信可能なIPアドレス)
$ kubectl get services sample-clusterip

# Pod内へのログイン
$ kubectl exec -it sample-pod /bin/bash
→sample-podはmetadata.nameフィールド名



・dockerコマンド関係

$ docker rmi <イメージID>  -f (dockerイメージの削除) 
$ docker images (dockerイメージ確認)
$ docker ps(コンテナが正常に起動しているか確認)

# コンテナ停止、削除
$ docker stop [コンテナ名]  (全コンテナ停止 docker stop $(docker ps -q))
$ docker rm [コンテ名] (全コンテナ削除 docker rm $(docker ps -q -a))



・Podのマウント先からkubectl cpでデータのコピー

apiVersion: v1
kind: Pod
metadata:
  name: dataaccess
spec:
  containers:
  - name: alpine
    image: alpine:latest
    volumeMounts:
    - name: mypvc
      mountPath: /data
  volumes:
  - name: mypvc
    persistentVolumeClaim:
      claimName: mypvc

=>コピーコマンドとS3の例

# コピーコマンド
$ kubectl cp dataaccess:/data data/

# S3からコピー
$ kubectl cp sample-tensorflow:s3://image/data

Amazon sagemakerで機械学習基盤(MLops)のベース環境構築手順・使い方まとめ

機械学習基盤(MLops)をkubernetesで構築してたけど、amazon sagemakerを使ったら、kubernetesよりかなり簡単に構築できた。今回は、学習コード用のコードは抜きで、学習済みモデルと推論コードのみでMLopsをAmazon sagemakerで構築した。その手順まとめてく。

目次
1.Amazon sageMakerについて

2.Step1.awsコマンドの設定、ECRでレポジトリ作成

3.Step2.ECRへログインし、dockerイメージ作成

4.step.3. ノートブックインスタンス、トレーニングジョブ、モデルの作成

5.step4.エンドポイントの作成

6.step5.postmanを使ったテスト環境下でのデプロイ

7.最後に


Amazon sageMakerについて

Amazon sageMakerは「開発、学習、デプロイ」の3つのフェーズに分かれてる。それぞれのフェーズで、用途に特化した専用インスタンスを利用し、従来の開発環境より、MLopsをかなり簡単に構築できる


開発フェーズ →Notebookインスタンス(jupyternote bookがインストールされた状態)

学習フェーズ → trainingインスタンス(dockerコンテナで環境構築)

デプロイフェーズ → HostingインスタンスAmazon sagemakerだけでAPIを提供)

最終的に学習済みモデルをHosting instanceでデプロイする。 デプロイのためには、estimator.deploy関数を呼び出すらしいけど、今回はpostmanを使ってデプロイした。

参考:Amazon SageMakerで実現する生産性の高い機械学習基盤






Step1.awsコマンドの設定、ECRでレポジトリ作成

まずはサイト通りにコンソールで定番のAWS CLI(AWSコマンド)を利用できるようにする。

$ aws configure


で「アクセスキー、シークレットアクセスキー、リージョン、json」を設定。

リージョンは東京(ap-northeast-1)。

次はAWSのホーム画面からECRを選び、ECR画面上でレポジトリを作成。
今回はGUIで「inception」という名前のレポジトリを作成







Step2.ECRへログインし、dockerイメージ作成



1.dockerのインストール

まずdockerにログインコマンドを発行する。

そのためにはdockerコマンド(docker)がインストールされてないとダメなので、公式サイト通りにdockerをインストール。

$ docker version

で色々出てくればOK

$ docker version
Client: Docker Engine - Community
 Version:           18.09.0
 API version:       1.39
 Go version:        go1.10.4
 Git commit:        4d60db4
 Built:             Wed Nov  7 00:47:43 2018
 OS/Arch:           darwin/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          18.09.0
  API version:      1.39 (minimum version 1.12)
  Go version:       go1.10.4
  Git commit:       4d60db4
  Built:            Wed Nov  7 00:55:00 2018
  OS/Arch:          linux/amd64
  Experimental:     false



2.コンソールからECRへアクセス

サイト通りに下のコマンドでECRにアクセス。


リージョン名はawsコマンド設定時のと同じ東京にした(同じじゃなくてもいいと思う)。
権限が付与されてれば、クソ長いdocker loginコマンドが出てくるので、それをコピペして、コンソールで実行。

$ aws ecr get-login --no-include-email --region ap-northeast-1
>>>docker login -u AWS -p ~xMjM5NTZ9 https://947675573067.dkr.ecr.ap-northeast-1.amazonaws.com

→docker loginコマンドでアクセス
$ docker login -u AWS -p ~xMjM5NTZ9 https://947675573067.dkr.ecr.ap-northeast-1.amazonaws.com



3.Dockerイメージ作成

step.1で作ったGUIのレポジトリ名"inception" を使い、dockerイメージ作成。
Dockerfileがあるディレクトリに移動してコマンド実行。

今回はあくまでベース基盤の構築方法なので、学習済みモデルと推論コード、それに必要なファイルしか置いてない。

# 移動
$ cd /Users/downloads/amazon_sage
# ファイル一覧
$ ls
>>Dockerfile	inception_ep8.h5  cnts.csv	main.py


ちなみにDockerfileの中身

FROM mxnet/python:latest
# 実行環境
WORKDIR /app
# コードのコピー
ADD . /app
# 念の為、学習済みモデルをコピーするのも書いといた
COPY inception_ep8.h5 /app/inception_ep8.h5
# ライブラリのインストール
RUN pip install -U numpy flask tensorflow keras
# コンテナ実行時に’python main.py’を実行
ENTRYPOINT ["python", "main.py"]
EXPOSE 8080

下コマンドでdockerイメージを構築。step.1のGUIで作成したレポジトリ名を使用。

$ docker build -t inception . ($ docker build -t <レポジトリ名> . )

# 成功
>>>
Successfully built 0b71de349169
Successfully tagged model_incep:latest




step.3. ノートブックインスタンス、トレーニングジョブ、モデルの作成

1.モデルの作成

AWSのホーム画面からAmazon sagemakerへ移動し、左の「モデル」を選択。

ここではおそらく、学習環境とそのモデルを作成する。

今回は最低限の操作(「推論コードイメージの場所」の設定とか)しかしてない。

→「推論コードイメージの場所」の設定

指定した英数字は

ECRログイン時のクソ長いdocker loginコマンドの末尾のhttp://~の部分

dockerイメージ作成時の’Successfully tagged model_incep:latest’の「model_incep:latest」を指定

の2つをを組み合わせたものを入れた。

947675573067.dkr.ecr.ap-northeast-1.amazonaws.com/model_incep:latest
(ecrログイン時の末尾のurl/docekrイメージ作成時のレポジトリ名:タグ)




2.ノートブックインスタンスの作成


左の「ノートブックインスタンス」から「ノートブックインスタンス作成」をクリック。

ノートブックインスタンスタイプは「ml.t2.medium」を指定した(色々機能ごとにあると思うけど、とりあえずこれを指定)。

「IAMロール」は権限が付与されえれば、自動で生成された。
あとの細かい設定はせず、とりあえず「ノートブックインスタンスの作成」で作成




3.トレーニングジョブの作成

Amazon sagemakerの左側の「トレーニングジョブ」から「トレーニングジョブの作成」を選択。

各種の設定(ここも最小限のしかしてない)


1.アルゴリズムのソース→ 「ECRでの独自のアルゴリズムコンテナ」を選択


2.コンテナECRのパス指定→「推論コードイメージの場所」で指定したやつと同じものを指定(947675573067.dkr.ecr.ap-northeast-1.amazonaws.com/model_incep:latest

3.「入力データ設定」と「出力データ先」でs3のパス指定

# 例
 s3://mage/image/ (s3://bucket/data/)


「トレーニングジョブの作成」ボタンクリックで作成







step4.エンドポイントの作成


左の「エンドポイント」から「エンドポイント作成」をクリックして、各種設定



1.エンドポイント設定のアタッチは「新しいエンドポイント設定の作成」


2.「エンドポイントの設定」ではstep.1のレポジトリ名’inception’で検索し、選択


「エンドポイントの作成ボタン」を押して完成






step5. postmanを使ったテスト環境下でのデプロイ



構築したモデルの本番環境はAmazon sagemakerでデプロイできる。
けどテスト環境は、postmanというツールをダウンロードして、デプロイする。


本番環境では、このサイト通りにすれば、Amazon sagemakerでデプロイできる。


Postmanについて

Postmanは、認証、テスト、ドキュメント作成、バージョン管理などAPIの多くの要素を統合していて、テススト環境でのデプロイに便利。
Postmanのセクションにはタブが5つあるらしい

Authorization:Basic AuthやOAuth2など、リクエストの認証方法を指定する

Headers:content-typeやAuthorizationなど、リクエストヘッダーを指定する

Body:PostやPUTなどの場合に指定するリクエストボディー

Pre-request Script:リクエスト前に実行するJSコード

Tests:レスポンスペイロードの妥当性を検証するJSコード



postmanを使ったテスト環境でのデプロイは、AmazonチュートリアルPostman を使用してAPI を呼び出す)を参考に。




最後に

これで、基本的なMLopsのベース基盤ができた。本格的に構築するなら、各フェーズで細かい設定が必要だが、環境構築からデプロイまでAmazon sagemakerで全てできる。


今回はあくまでベース基盤の構築なので、開発、学習、デプロイのフェーズまでは最小限の操作にとどめて、手順をまとめた。

あとは実用レベルに必要な設定をすれば、Amazon sagemakerだけで本格的なMLopsが構築できる。



参考サイト
Amazonsagemakerで開発からデプロイまで

アソシエーション分析を使ったレコメンドアルゴリズム作成-機械学習・python

レコメンドは普通、評価値(レーティング)を使った手法がメインだが、今回は都合でレーティングがない環境下で、レコメンドアルゴリズムを作らなきゃならなかった。
そこで、アソシエーション分析を使ったレコメンドアルゴリズムを作ったので、その過程をまとめてく



目次
・アソシエーション分析のレコメンドについて
・特徴量エンジニアリング
アプリオリアルゴリズムでメタラベル作成
・協調フィルダリング
・ランクネット
・レコメンド結果
・最後に
・気づいたこと、テクメモ



アソシエーション分析のレコメンドについて



主にレコメンドは評価値(レーティグ)を使う/使わないの2手法で2つに大別できる。

①レーディングを使うアルゴリズム→ユーザーの購入意図を推測するレコメンド

②レーティングを使わないアルゴリズムトランザクション履歴などの過去の購買データから、商品間の一般的な購買ルール(依存関係)を見つけ、そこからレコメンドを作成する


今回はレーティングがないので、②のタイプのレコメンドモデルを作成。
このレコメンドの対象商品は主に下のタイプ。

・人気の高いアイテム

・トレンドアイテム

・ニューリリースアイテム

・類似アイテム

・頻出アイテム

この手の手法は個人の趣向を考えない(パーソナライズしない)タイプのレコメンドに該当する。
今回のレコメンドは「類似アイテム」をレコメンドすることが目的。

対象ユーザーは、購入済みユーザーだけに類似アイテムをレコメンドし、新規ユーザーは対象外。


使うアルゴリズムは、協調フィルダリングとランクネットを使った複合アルゴリズム



具体的なやり方は4つのパラメータ(モデル、価格帯、性別、評価値)を使って、類似アイテムを取り出す。
まず協調フィルダリングで3パラメータ「モデル、価格帯、性別」を使い100万単位のアイテム候補から百~千単位に絞ったあと、ランクネットで評価値パラメータを使い、高評価順に並び替え、上位N個表示する。




手法youtubeAmazonを参考にした。

youtubeは似たような複合アルゴリズムを使っていたから。
amazonは、データ分析計の本を購入したら、「同シリーズ、同カテゴリ、ほぼ同価格帯、高評価」の商品をレコメンドしてきて、感激したので。
その流れで複合アルゴリズムで、amazonみたいな4パラメータで類似商品をレコメンドするアルゴリズムを作ることにした。


コードはgithubに上げてあります



特徴量エンジニアリング


選んだ特徴量

特徴量は一回の取引に付属するユーザーの行動・アイテムのデータを使用。
(SQLで取り出すときは商品系、会員情報、取引系のテーブルのレコードをgroup by)。


ユーザーが必ずしない行動を含んだテーブルのレコードは「LEFT OUTER JOIN」か「FULL JOIN」して、後はpandasとかで欠損値を0にして使った。

最近のレコメンドはfactorizition machinesのようにいろんな特徴量を含めれるようになってるので、特徴量はユーザーデータとアイテムデータ関係を使った。

ユーザーデータ → ユーザーの行動データ、ログイン端末(スマホとか)、ユーザーの個人情報、ログインした場所・時間とか

アイテムデータ→アイテムデータ関係で使えそうなものは全部。



分析例

タイムデータはヒートマップで分析するとログイン時間は朝方が多いっぽい


端末はスマホが圧倒的に多かった



地理データは、今回はほぼ日本ユーザーしかいないので必要なかった。(youtubeみたいな世界規模なら必要だけど)

地理データを調べてみたら、ログインした場所 (県)は「東京、大阪」の大都市が最も多かったので、そういう場合は、
SQLのCASE文で多い箇所を1、他0
みたいに、「多い場所を特別扱いする」ように加工すると使いやすいかと思う。



あと特徴量選択でやったこと

①レコメンドは「UXのデザイナーみたいに仮説立てるのが大事」みたいな記事読んで、UX関係の人に広告で重要なデータ、立てた仮説、ユーザーのニーズとかを聴いた


YouTubeの論文とかkaggleで使われてる情報を参考に特徴量選択


③特徴量選びは、下の順序でやった
1.会員、取引、商品関係のテーブル表眺めて、使えそうなものを選ぶ
2.xgboostと重回帰分析にかけて、説明変数と目的変数の関係の仮説を立てる。
3.仮説と分析結果から総括して選択


④広告に重要なデータの傾向
→ userごとによる違いが顕著なデータ(セッション回数に関係するものとか)

→Userのニーズ判断に重要な過去行動、ネガポジフィードバックの情報

→UXで立てた & 改善した仮説で重要な「クリック率、表示速度」とか



youtubeレコメンドの特徴量分析
予測対象:視聴時間
特徴量:動画の視聴時間の予測に関係する特徴量は全部使ってる

動画情報=>動画関係は必須
地理情報=> 世界的ユーザーがいるので
性別 => 男女で好みがあるから
探した情報=> 見たい動画に探す行動は大きく反映するから
年 => 年齢層で見るもの違うから





アプリオリアルゴリズムでメタラベル作成


今回はlabelに使用するレーティングのデータがないので、labelはバスケット分析の一つ「アソシエーション分析(by アプリオリアルゴリズム)」からルールを見つけ作った。


目的は自社サイト独自の購買ルールを探し、そこで見つけたルールに沿ったベストなlabelを作成するため。


ラベルの作成
「同じカテゴリの商品を買った人=似ている」
と仮定して、会員IDとカテゴリIDからトランザクション履歴を作り、アプリオリアルゴリズムで分析。

どうやら
「似た用途のカテゴリを買った人は似た用途の商品を購入する」
ルールがあるみたいなので、これを使った。


そこで、カテゴリIDは1000~9000まであるので、似た用途のカテゴリIDを91この新たなカテゴリに分類。0~91の新たなメタデータを作ってラベルにした。

ラベルは協調フィルダリングでtop5を予測する形で使う。


協調フィルダリング



普通レコメンドではfactrizavtion machines(FM)みたいなアルゴリズムを使うことが多い。しかし、FMは計算量が多いし、最近ではyoutubeのようなRNN系のneural network(NN)を使ったケースが人気らしい


(参考→youtubeの論文

今回はレーティングのデータがないからyoutubeを真似したNNをFMの代わりに使った。
特徴量はユーザーとアイテム関係のデータともに商品を推測するのに有益な構成になった。


youtubeの論文だと層が厚い方が精度がよくなるらしいので、層は2048層

def c_filtering(INPUT_DIM, weight_path):
    classes=91
    model = Sequential()
    model.add(Dense(2048, input_dim=INPUT_DIM, activation='relu'))
    model.add(Dense(1024, activation='relu'))
    model.add(Dense(512, activation='relu'))
    model.add(Dense(256, activation='relu'))
    model.add(Dense(classes, activation='softmax'))
    model.load_weights(weight_path)
    model.compile(optimizer='Adam',
                    loss='categorical_crossentropy', 
                    metrics=['accuracy'])
    return model


NNの結果

正解率は85.7%
類似したラベルのtop5の確率値出すので、間違ってもほとんど問題ないと思う



類似アイテムを取り出す順序
①まず予測したlabel(メタデータ)上位5つと同じやつに絞る

②高評価以外のモデル、価格帯、差別の3つのパラメータの抽出条件の関数作り、抽出

③最終的に絞ったアイテム数は876142件 => 185件。

④あとはランキング学習で評価パラメータを使い、高評価順に並び替える




抽出条件の関数

def kbn_type(target_df, dataset):
    kbn_colmuns=['model', 'line','series','kigata']
    kbn_type=[]
    for kbn in kbn_colmuns:
        for value in target_df[kbn]:
            if value==1:
                kbn_type.append(kbn)
    kbn_type=','.join(kbn_type)
    return dataset[dataset[kbn_type]==1]


def tanka(target_df, dataset):
    price=int(target_df['tanka'])
    yen=2000
    tankset=dataset[(dataset['tanka']>=(price-yen)) & (dataset['tanka']<=(price+yen))]
    return pd.DataFrame(tankset)


def sex(target_df, dataset):
    sex_colmuns=['man', 'women']
    sex_type=[]
    for sex in sex_colmuns:
        for value in target_df[sex]:
            if value==1:
                sex_type.append(sex)
    sex_type=','.join(sex_type)
    return dataset[dataset[sex_type]==1]




ランクネット


レーティングがないので、ランクネットの評価値は、「高評価の指標に関係しそうな特徴量」に重み付けして代用。もちろん使うときは、正規化した。


Ranknetを使う発想はyoutubeがNNの後にRankNetを使う複合アルゴリズムを使ってたのを参考にした

def Ranknet(INPUT_DIM):
    model = Sequential()
    model.add(Dense(INPUT_DIM, input_dim=INPUT_DIM, activation='relu'))
    model.add(Dropout(0.1))
    model.add(Dense(64, activation='relu'))
    model.add(Dropout(0.1))
    model.add(Dense(32, activation='relu'))
    model.add(Dense(1))
    model.compile(optimizer='Adam', loss='binary_crossentropy', metrics=['accuracy'])
    return model

Ranknetの関係でわかったこと、やったこと


YouTubeは見た感じ、次のタイプの特徴量を使ってるよう
→印象的な・視聴したビデオid
→ユーザーの母国語
→ビデオの言語
→最後に見てからの時間
→(印象的な)動画に関係する過去のユーザー情報


・CTR(クリック率)は「ユーザーとの相性や貢献度が高い/クリックしたい衝動の指標」であって、評価とはほぼ関係なく、ラベルに使わなかった


・特徴量選択は協調フィルダリングと同じ分析手法を使った。評価に関係ありそうなユーザーの行動データ、アイテムのデータを選択





評価値の作成と訓練・評価

まず特徴量の内訳


rating →着レポとかいう商品の評価らしい(全部の商品についてない)

popularity_boost →人気度係数

minyuka_tag →タグ(日本未入荷タグ)

teikei_cd→特集ページからの流入

ps →新規か常連ユーザーか

add_fav →お気に入りに登録

view_detail →商品詳細を見た

buyer_rating_good →セラーへの評価

safety_tesuryo →安心プラス


評価値の作成は計算式を作った。重回帰分析の寄与度のスコアから、重み付けの値を決定。(普通に足し算するより、レコード数で割った方が精度がよかった)


・計算式
rank= (rating×0.01+ popularity_boost×2+ minyuka_tag×3 + teikei_cd×3 + ps×3 + add_fav×4 + view_detail×3 + good×3) /8


・評価指標はNDCG
結果は100%(計算式を使ってるから当たり前)
あとはふつうにranknetでtrain/test

def ndcg(y_true, y_score, k=100):
    y_true = y_true.ravel()
    y_score = y_score.ravel()
    y_true_sorted = sorted(y_true, reverse=True)
    ideal_dcg = 0
    for i in range(k):
        ideal_dcg += (2 ** y_true_sorted[i] - 1.) / np.log2(i + 2)
    dcg = 0
    argsort_indices = np.argsort(y_score)[::-1]
    for i in range(k):
        dcg += (2 ** y_true[argsort_indices[i]] - 1.) / np.log2(i + 2)
    ndcg = dcg / ideal_dcg
    return ndcg

print(ndcg(y_test, pred))


レコメンド結果


ターゲットユーザーの商品内容は
ブランド→シャネル
カテゴリ→コインケース・小銭入れ


レコメンドした商品内容は下図



ちゃんとアソシエーション分析で見つけたルール通り、同じメタカテゴリに含まれてるカテゴリ商品がレコメンドされてる。形式的にはうまくいったと思う





最後に


今回は初めてレコメンドアルゴリズムを作成した。レーティングがない状況下で、チートなレベルだったけど、なんとか形になるものができた。

レコメンドの設計方法、特にアルゴリズムの組み合わせ、類似度抽出の方法とかは、アルゴリズム作成者の思案次第で無限に組み合わせがある。
老舗の業者見てるともっと凝ったレコメンドはかなりあるので、他サイトを見てみるのは、レコメンド作成でかなり参考になった。





気づいたこと、テクメモ



・今回は商品の個体を識別するデータがなかった

iPhone 10のような商品の個体を識別するデータがない、ので同一商品をレコメンドする可能性があり困った


・ID系の膨大なカテゴリ変数の加工テク

→word2vecで類似度変換して量的変数にするとか


→一番頻度の高い順にでランク付け、頻度が少ないやつは0として扱う


→別々の変数同士を組み合わせて新たな変数を作る(ex:カウント数順で上位112目
brand_id=256, model_id=0, cate_id=3105 => cnt=112)

クラスタリング(k-means)で画像から色の検出(機械学習、opencv)

今回はクラスタリング手法で、画像の重要な色を検出するタスクをやった。ニューラルネットワークならより正確な検出が可能だけど、データセット作成もろもろコストがでかい。なので、昔からあるクラスタリング手法で手軽に、かつ精度よく色を検出してみた。そのロジックと工程を書いてく。

目次
・rgb値から色を特定する方法
・用意したもの&ライブラリ
クラスタリングで色検出
・色同士のノルム計算で色の特定
・実験


全体コードはこちら

rgb値から色を特定する方法



色はrgb値の3つの値で表される。赤なら255:0:0だし、紫なら128:0:128。各3つの値のとる範囲は0〜255。rgb値はよく数学であるxyz空間と同じで、rgb空間上の点で表せる。

rgb空間は図で表すと下のような、6角形の形。


数学では三平方の定理とかでよく出てくるけど、「ベクトル間の距離(ノルム)が近いもの同士は似てる」という性質がある。


色でも同じ性質が使えて、rgb値のノルムが近いほど、互いの色が似ていることを表す。
この性質を使って、

・rgb値のノルムが近いもの同士=似た色

として色を検出する。



ただし、rgb空間は六角形なので、xyz空間のように3次元に変換してやる必要がある(座標変換)。rgb空間の3次元空間はlab空間というらしい。

lab値の各値のとりうる範囲は

L: [0, 100]
a: [-127, 127]
b: [-127, 127]

これでノルムを計算する。




色を特定する手順はこんな感じ

①色のlab値のリストの作成

②rgbで画像の読み込み

③rgb値のままクラスタリング

④skimage.color.rgb2lab()でrgbをlabに変換

⑤リストのlab値と、読み込んだ画像のlabのノルムを計算。リスト内の色と類似した色のtop4を特定



lab値の変換について
ライブラリのopencvでrgbをlabに変換すると、値は0〜255の範囲のまま。それだと困るので、ここではskimageというライブラリを使う。skimageならlabの値の範囲内で正確に変換してくれる。

参考サイト





用意したもの&ライブラリ


ライブラリ
opencv=> rgb値での画像読み込み用

・skimage => rgb値をlab値に変換用

・k-means => クラスタリング



用意したもの
・色のlab値のリスト

=> lab値の取得は下記のサイトを使った
https://syncer.jp/color-converter

・試す画像複数枚 =>上手く検出できるか試す画像






クラスタリングで色検出



まず、下の画像をopencvでrgbで読み込む。

def load_image(image_file):
    # cv2 load images as BGR
    image_bgr=[cv2.imread(image_file+'/'+i) for i in os.listdir(image_file)]
    image_rgb = [cv2.cvtColor(i, cv2.COLOR_BGR2RGB) for i in image_bgr]
    image_rgb = [cv2.resize(i, (150, 150)) for i in image_rgb]
    return image_rgb


img = load_image("/Users/d/clust_img")
img=np.reshape(img, (len(img),150,150,3))
hstack=np.hstack(img)
plt.imshow(hstack)
plt.show()

そのままこの画像をクラスタリング、して色の抽出。最後は、5色のrgb値を算出してる。
メインの関数はコード参照


img = load_image("/Users/tatsuyahagiwara/d/clust_img")
img=np.reshape(img, (len(img),150,150,3))
b,w,h,c=img.shape
N=7
sample_img=[i[int(w/10):int(N*w/10), int(h/10):int(N*h/10)] for i in img]
sample_img=[i.reshape(-1,3) for i in sample_img]
color=[]
for i in sample_img:
    clt = KMeans(n_clusters = 5)
    clt.fit(i)
    hist = centroid_histogram(clt)
    bar = plot_colors(hist, clt.cluster_centers_)
    plt.figure()
    plt.axis("off")
    plt.imshow(bar)
    plt.show()

k-meansはお手頃だし、割と正確に検出できたので使った。クラスタリングした結果の一部はこんな感じ。








色同士のノルム計算で色の特定



算出したrgb値をskimage.color.rgb2lab()でlab値に変換。
あとは関数でリストの色と読み込んだ色のlab値のノルムを計算して、一番近い(似ている)色を出した。

for i in sample_img:
    clt = KMeans(n_clusters = 5)
    clt.fit(i)
    hist = centroid_histogram(clt)
    bar = plot_colors(hist, clt.cluster_centers_)
    plt.figure()
    plt.axis("off")
    plt.imshow(bar)
    plt.show()
    bar_lab=rgb2lab(bar) # LAB値に変換
    c1, c2, c3, c4, c5=get_lab_from_list(bar_lab[0].tolist())
    dic1, dic2, dic3, dic4, dic5=lab_distance_dic(color_name, lab_list, c1, c2, c3, c4, c5)
    t1,t2,t3,t4,t5=get_topN_color(dic1,dic2,dic3,dic4,dic5)
    color.append([t1,t2,t3,t4,t5])

main_color=[]
for i in color:
    for n in i:
        main_color.append(n[0][0])
        
N=sorted(list(collections.Counter(main_color).values()),reverse=True)
for key, value in collections.Counter(main_color).items():
    if value in N[:4]:
        print('top_color:{}'.format(key))

色のtop4算出結果はこんな感じ。割と画像のメインの色を特定できてる感じなので、十分使えると思う。

ブラック系
ピンク系
ブルー系
グレー系



精度を上げるためにしたこと


メインのカラーが全部で13 。なので、色ごとに一つのlab値(青なら青一色のlab値)しか用意してない場合、単一のlab値でしか色を判断できない。
しかし、色一つとっても、その色に近い色はたくさんある。(例えば青でも、青っぽい色はたくさんある)
http://irononamae.web.fc2.com/colorlist/wa.html




なので、精度を上げる方法としては、

①増やしたい代表色(列の一番目)を決める

②代表色に似ている色とそのlab値を、列の2番目以降に入れる。




こうすれば、代表色をいろんなlab値で表せるので、精度は上がる。今回は青、イエロー、赤を増やしてみた結果、精度が上がった。

(ex:青っぽい色を全部「青」と区分すれば、「青っぽい色=全て青」となるので、いろんなlab値でも青と特定できる)。





実験



一応実験として、下の画像群でも色の特定をしてみた

やることは上の作業を別の画像でやるだけ


クラスタリング結果と検出した色名は以下の通り


ブラウン系
ピンク系
ブラック系
グレー系
イエロー系

赤とブラウンの区別が難しいので、主観でlab値を選んだので、検出結果はよくわからない。
総括では、割といい感じにできて、実用できるレベルだと思う




ガチのところは、ニューラルネットワークを使ってかなり精度高く色検出やってる(メルカリとか)。ただ、管理、データセット作成、計算もろもろコストがでかすぎるのが難点。その分、k-meansみたいな昔からある手法ても、工程さえしっかりやれば、簡単かつ高い精度でできるケースのが多い。アルゴリズムは手段にすぎないので使えさえすれば、k-meansでも十分精度の良いものができた。

AWSのGPU環境下でkerasを使った百万単位(ビックデータ)の画像分類の訓練、テスト、予測までの過程まとめ

今回はkerasを使って、AWSGPU環境下で5百万枚の画像を訓練してみた。ラベル数は200ラベル。おそらくビックデータと呼ばれる規模だと思う。エラーとか、障壁が多々あったので、備忘録もかねて工程を一通りまとめてく

目次
・EC2にGPU適用&jupyter環境構築
・tfrecordから画像の読み込み(input image)
・訓練(training)
・学習率
GPU環境下での訓練
・モデルの保存
・テスト・予測
・最後に

コードはこちら





EC2にGPU適用&jupyter環境構築



EC2インスタンスGPUの適用

Deep learning ubuntu(linux)で検索、インスタンス作成、作成時に「EPS-optimize( EPS最適化)」を選択

②あとはチュートリアル通りに最後までやれば、GPU環境を構築できる。
(https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/install-nvidia-driver.html )





**補足**

チュートリアルの下のコマンドはlinuxパッケージのみ必要らしい。

sudo yum erase nvidia cuda


GPUの型番を確認コマンド ([ ]の中がGPUの型番に該当)

sudo update-pciids 
lspci | grep -i nvidia 




jupyter notebook 設定について

① 以下のサイトの$ipython以降を実行し、パスワードとか設定https://qiita.com/Salinger/items/c7b87d7000e48be6ebe2

② EC2インスタンスでjupyter用のIPアドレスの設定

EC2のインスタンス画面→対象のインスタンスのセキュリティグループを選択→インバウンドから編集→カスタムTCPルールのポート範囲/送信元を設定(8888/カスタム)

③必要なパッケージpipでインストール

④次のコマンドでjupyterの起動

$jupyter notebook & 

⑤jupyter上でGPU適用の確認 (「nvidia-smi」コマンドはGPU動作中のみ有効)

from tensorflow.python.client import device_lib
device_lib.list_local_devices()

上のコマンド打って、「name: "/device:GPU:0"〜」みたいなメッセージが出ればOK







tfrecordから画像の読み込み(input image)



だいたい、cifar-10のチュートリアル通り。けど、バッチを作るメソッドの

tf.train.shuffle_batch()

は画像枚数が大きすぎると、メモリを食い過ぎてショートする。tfrecordを複数にしても、ラベルが上手くシャッフルできないせいか、損失関数が上手く減少しない。ここら辺のロジックは、なかなかセオリー通りにいかないので、以下のコードで読み込ませた。

def distorted_input(data_files, batch_size, train=True, num_readers = 60):
    num_class=200
    if train:
        filename_queue = tf.train.string_input_producer(data_files, shuffle=True, capacity=16)
    else:
        filename_queue = tf.train.string_input_producer(data_files, shuffle=False, capacity=1)
    num_preprocess_threads = 60
    examples_per_shard = 1024
    min_queue_examples = examples_per_shard * 16
    if train:
        examples_queue = tf.RandomShuffleQueue(capacity=min_queue_examples + 3 * batch_size,
                min_after_dequeue=min_queue_examples, dtypes=[tf.string])
    else:
        examples_queue = tf.FIFOQueue(capacity=examples_per_shard + 3 * batch_size, 
                                      dtypes=[tf.string])

    # Create queue
    if num_readers > 1:
        enqueue_ops = []
        for _ in range(num_readers):
            reader = tf.TFRecordReader()
            _, value = reader.read(filename_queue)
            enqueue_ops.append(examples_queue.enqueue([value]))
        tf.train.queue_runner.add_queue_runner(
            tf.train.queue_runner.QueueRunner(examples_queue, enqueue_ops))
        example_serialized = examples_queue.dequeue()
    else:
        reader = tf.TFRecordReader()
        _, example_serialized = reader.read(filename_queue)
        
    images_and_labels = []
    for thread_id in range(num_preprocess_threads):
        image, label_index = parse_example_proto(example_serialized)
        images_and_labels.append([image, label_index])
    
    images, label_index_batch = tf.train.batch_join(images_and_labels,
             batch_size=batch_size, capacity=2 * num_preprocess_threads * batch_size)
    
    height = 150
    width = 150
    images = tf.reshape(images, shape=[batch_size, height, width, 3])
    
    return tf.subtract(tf.div(images,127.5), 1.0), tf.one_hot(tf.reshape(label_index_batch, [batch_size]), num_class)


def parse_example_proto(serialized_example):
    height = 150
    width = 150
    features = tf.parse_single_example(serialized_example,
                        features={"label": tf.FixedLenFeature([], tf.int64),
                                  "image": tf.FixedLenFeature([], tf.string)})
    label = tf.cast(features["label"], tf.int32)
    imgin = tf.reshape(tf.decode_raw(features["image"], tf.uint8), tf.stack([height, width, 3]))
    image = tf.cast(imgin, tf.float32)
    distorted_image = tf.image.random_flip_left_right(image)
    distorted_image = tf.image.random_brightness(distorted_image, max_delta=63)
    distorted_image = tf.image.random_contrast(distorted_image, lower=0.2, upper=1.8)
    distorted_image.set_shape([height, width, 3])
    return distorted_image, label

num_readers と num_preprocess_threadsの数を増やしてやれば、大容量でも上手く読み込めた。枚数が億単位になるとできるか不明。

batch数は32×GPU数(今回は32×8=256)が一番安定して、損失関数が減少した。






tfrecordファイルの作成について

tfrecordはprotocol buffer 形式で画像とラベルがペアで入ってる。けど、tfrecordにこだわらず、pickleファイルとか他のファイルでも問題ない。
tfrecordはたまたま使いやすいから、個人的に使っているだけ。枚数が増えてメルカリみたいに転移学習を用いる環境下ではむしろtfrecordは向いてないので、適材適所でファイルは使うべき。

ちなみに1ラベルごとに1つのtfrecordを作成したので、合計200ファイル。ラベルごとの作成過程は、
urlを呼び出してcsvに保存→s3からディレクトリに保存し→tfrecord作成

まで一連の過程を1ラベルごとにした。


label_0.csv → label_0(ディレクトリ)→train_label_0.tfrecords

こんな感じでやると以下の利点があって、一番効率がよく作成できた。



・問題が起こってもすぐ作り直せる

・for文で連鎖的に呼び出せる

path='/home/ubuntu/train_tf'
filenames = [os.path.join(path, 'record_%d.tfrecords' % i) for i in range(0, 200)]


・ミスなく作れる


ちなみに全tfrecordの中身の画像枚数は下のコードでカウント

# pathの読み込み
path=[os.path.join('/home/ubuntu/train_tf/record_%d.tfrecords' % i) for i in range(0, 200)]

# データ数カウント
cnts=0
for i, p in enumerate(path):
    cnt = len(list(tf.python_io.tf_record_iterator(p)))
    print("NO:{}, データ件数:{}".format(i, cnt))
    cnts+=cnt
print("合計データ件数:{}".format(cnts))





訓練(training)



モデルはinception_ resnet_v2を使用。




訓練のメイン部分

sess = tf.Session(config=tf.ConfigProto(allow_soft_placement=True, log_device_placement=False))
K.set_session(sess)

# lr = tf.train.exponential_decay(0.1, global_step, decay_steps, learning_rate_decay_factor, staircase=True)

def step_decay(epoch):
    initial_lrate = 0.01
    decay_rate = 0.5
    decay_steps = 8.0
    lrate = initial_lrate * math.pow(decay_rate,  
           math.floor((1+epoch)/decay_steps))
    return lrate


NUMN_GPUS = 8
batch_size=32*NUMN_GPUS

train_image, train_labels=distorted_input(filenames, batch_size, train=True)

with tf.device('/cpu:0'):
    train_model= InceptionResNetV2(train_image)
pmodel= multi_gpu_model(train_model, gpus=NUMN_GPUS)
pmodel.compile(optimizer=SGD(lr=0.01, momentum=0.9, decay=0.001, nesterov=True),
                    loss='categorical_crossentropy', 
                    metrics=['accuracy'], target_tensors=[train_labels])

# callback
history = History()
callback=[]
callback.append(history)
callback.append(LearningRateScheduler(step_decay))
  

tf.train.start_queue_runners(sess=sess)
coord = tf.train.Coordinator()
threads = tf.train.start_queue_runners(sess, coord)
try:
    pmodel.fit(steps_per_epoch=int(5101031/batch_size), epochs=3, callbacks=callback)
finally:
    train_model.save('/home/ubuntu/check_dir/inception_model_ep8.h5')
coord.request_stop()
coord.join(threads)

K.clear_session()


損失関数はこんな感じで順調に減少。3エポックくらいから減少しなくなった。





学習率
cifar-10とかで使ってる学習率の調整用メソッド
tf.train.exponential_decay()はkerasの場合、次で代用できる。

(tf.train.exponential_decayの引数「global_step」は kerasではepoch)

def step_decay(epoch):
     learning_rate = 0.01
    decay_rate = 0.5
    decay_steps = 8.0
    lrate = learning_rate  * math.pow(decay_rate,
           math.floor((1+epoch)/decay_steps))
    return lrate

これをcallback.append(LearningRateScheduler(step_decay))
でcallbackに入れれば、epochごとに最適な学習率に調節してくれる。パラメータは自分で調整した。

resnet系のoptimizerのベストプラクティスに関しての詳細はここら辺のサイトに詳しくのってる。→参考サイト

SGD+Momentumとnesterov=Trueがベストプラクティスらしい。パラメータはチュートリアルとか、論文読みあって総括して決めた






GPU環境下での訓練
kerasの場合、複数のGPUを使用するなら、modelをmulti_gpu_model()の引数に代入する必要がある。インスタンスタイプのp2_8xlargeはGPUを8こ搭載してるので、gpus=8にしてる。

またモデルの読み込みはCPU上でやらないと、OOMエラーが出る

with tf.device('/cpu:0'):
    train_model= InceptionResNetV2(train_image)

またOOMエラー対策かわからないけど、GPU使うときはtf.Session内で、次のおまじないが必要。

sess = tf.Session(config=tf.ConfigProto(allow_soft_placement=True,
log_device_placement=False))




モデルの保存
訓練はmulti_gpu_model()メソッドで作成したモデルでやってるけど、
保存はmulti_gpu_model()メソッドに入れたモデル(ここではtrain_model)を保存しないとダメ。このときモデルの保存にcallbackの
ModelCheckpointはmulti_gpu_model()メソッドのモデルを保存してしまうので使えない。

またモデルの保存は訓練終了時に指定。

coord = tf.train.Coordinator()
threads = tf.train.start_queue_runners(sess, coord)
try:
    pmodel.fit(steps_per_epoch=int(5101031/batch_size), epochs=3, callbacks=callback)
finally:
    train_model.save('/home/ubuntu/check_dir/inception_model_ep8.h5')
coord.request_stop()
coord.join(threads)








テスト・予測



テスト、予測のメイン部分のコード

sess = tf.Session(config=tf.ConfigProto(allow_soft_placement=True, log_device_placement=False))
K.set_session(sess)

NUM_GPUS=8
batch_size=125*NUM_GPUS

test_image, test_labels= distorted_input(filenames, batch_size)
with tf.device('/cpu:0'):
    test_model = InceptionResNetV2(test_image)
test_model.load_weights('/home/ubuntu/check_dir/inception_model_ep8.h5')
test_model= multi_gpu_model(test_model, gpus=NUM_GPUS)
test_model.compile(optimizer=SGD(lr=0.01, momentum=0.9, decay=0.001, nesterov=True),
                    loss='categorical_crossentropy', 
                    metrics=['accuracy'], target_tensors=[test_labels])


coord = tf.train.Coordinator()
threads = tf.train.start_queue_runners(sess, coord)

_, acc = test_model.evaluate(x=None, verbose=1, steps=100)
print('\nTest accuracy: {0}'.format(acc))

y_pred = test_model.predict(x=None,verbose=1,steps=100)
LABEL=[]
for i in range(100):
    LABEL.append(sess.run(test_labels))
top_k=sess.run(tf.nn.top_k(y_pred,k=3,sorted=False))
coord.request_stop()
coord.join(threads)

test_model.evaluate(x=None, verbose=1, steps=100)のstepsとバッチサイズは、読み込むテスト画像とぴったり合うようする必要がある。
今回はテスト画像枚数が10万枚なので、steps=100 、batch_size=125*8
にして、1000バッチを100回繰り返して、10万枚の画像を読み込んだ。

また評価用にラベルもtfrecordから取り出した。

LABEL=[]
for i in range(100):
    LABEL.append(sess.run(test_labels))

あとは混合行列と、f値で評価

# Accuracy , F-score.etc
print('acuracy:{}'.format(accuracy_score(label_batch,f_pred)))
label_string = ['{}'.format(i) for i in range(200)]
print(classification_report(label_batch, f_pred,target_names=label_string))





最後に


今回は、

・環境が整ってない
・5百万単位の大規模データでの訓練

の2つが要因でエラーが多々発生した。
反省点としては


・停滞したら、立ち止まって調査、考える時間にあてる
・思考時間とPC作業の割合は7:3くらいが自分にはベストプラクティス

これ以上枚数が増えると、一部の界隈(メルカリ)みたいに

転移学習+次元削減(PCAとか)+faiss

を使うような、効率性を追求した環境構築の必要あると思った。




EC2、S3関係のコマンドメモ



・キーの作成
# ec2インスタンスへのログインキーの作成・保存

cp path/to/フィル名.txt ~/.ssh/任意の名前

chmod 400  ~/.ssh/名前


sshでログイン

$ ssh -i ~/.ssh/任意の名前 root@ 

・scpコマンドで、フォルダorファイルコピー
# EC2からローカルにファイルのコピー

scp -i ~/.ssh/aws_develop_sec /Users/Downloads/test.tfrecords ubuntu@13.231.234.143:/home/ubuntu/test.tfrecords 

# ローカルからEC2へファルダのコピー

scp -r -i ~/.ssh/aws_develop_sec /Users/Downloads/prediction_img centos@13.231.108.156:/home/centos/prediction_img

# EC2からローカルへフォルダのコピー

scp -r -i ~/.ssh/aws_develop_sec centos@13.231.234.17:/home/centos/test /Users/Downloads/tfrecords/test 


# EC2からs3へフォルダのアップロード

aws s3 cp a s3://cnn-image --recursive --acl public-read --cache-control “max-age=604800"

# S3にディレクトリごとアップロード

$ aws s3 cp <ディレクトリ名> s3://<バケット名>/<バケット名>/ --recursive --acl public-read --cache-control "max-age=604800"


# sshのログインがキレてもpythonを動かし続けられるコマンド(バックグラウンド→ https://qiita.com/alancodvo/items/15dc36d243e842448d33

$ nohup python Inception_keras_gpu_train.py & 

xgboostの回帰モデルで精度検証から重要な特徴量選択までやってみた全行程まとめ(機械学習)

今回はkaggleでよくある特徴量エンジニアリングのテクを使って、精度向上から重要な特徴選択までをやった。普通は精度高ければ終わり的な感じだけど、今回は精度検証からさらに掘り下げて、特徴量の選択までやったので、その過程を書いてく。

目次
・プロジェクト紹介
・デーセット
・特徴量作成
・モデル構築
・正解率
・特徴量の探索(分析)
・最後に(反省点)

コードはこちら




プロジェクト紹介


一回しか購入しないユーザー(u1)と2回以上購入しているユーザー(u2)を分ける重要な特徴量を見つけるプロジェクト。目的は一回しか買わないユーザーに有効な施策をうつこと。

よくある特徴量エンジニアリングで、回帰モデルを使って精度を競うkaggleとかのコンペと似ている。けど、今回は精度検証後に重要な特徴量選択までしたので、特徴量選択の分析過程も含まれてる。全体の工程としては、特徴量作成が7、8割を占めた感じ。

特徴量エンジニアリングが、精度向上に重要な過程だと再認識。





データセット


データセットはデータベースに保存してあるテーブルのレコード(特徴量)から選択して作成。kaggleみたいにあらかじめ綺麗なのが用意されてないし、データベースとbigqueryに別々に保存してある。そんな中、無数にある特徴量から適切なのをSQLで取り出して、ベストな特徴量を作成。

最終的な特徴量は、2回目の検証結果から決めた。アルゴリズムでの検証は全体で3回。

まず1回目は商品に結びつきそうな特徴量を選んで検証。1回目はtouroku_dateという特徴量が一番重要らしく、次の特徴量を選ぶ指針になった。

2回目は施策から「u1が1回で購入をやめてしまう」ことに関する仮説を立てて、この仮説に直結する特徴を選んで使うことにした。
仮説の設定はAmazonとか、リピート率が鬼のように高いサイトレビューとかを参考にした。


一回で購入をやめてしまう or 継続して購入する理由の仮説

=> 評価の悪いユーザーから購入してる
=> レコメンド関係の施策で欲しい商品を購入してる
=> 欲しい商品が見つからない(時間をかけて探していない)



3回目は特徴量を2個追加して、若干の修正後に再検証。






***仮説を立てることで作業を効率化
無数にある特徴量の中から、適切なものを選択する過程で、施策に応じて仮説を立てる作業はかなりの効率化につながった。はじめに仮説を立てるのは、データサイエンティスト的な手法らしい。

さらに、選んだ特徴量から、u1とu2間の違いをあらかじめ調べる作業をする事で、さらに効率化。違いを見るのには「標準偏差・平均・中央値」とかの分析手法を使った。

特徴量は何を選べばいいかわからない状況下で、

・仮説の設定

・違いを見る作業

この二つをすることで、かなり効率化できた。

特徴量に必要な特徴量を選択するまでの、全行程はこんな感じ

1、まず分類に使えそうな特徴量を作成・検証

2、試作にあった仮説を立てる(重要)

3、仮説に直結する特徴量を選んで、標準偏差とかで違いをみる(重要)

4、特徴量を決めて、作成

2、3の作業が一番精度向上につながった。

最終的なデータセットは、
特徴量10こ
u1は250853件、u2は1169898件のデータを使った。




特徴量作成


特徴量の作成過程は上で書いた通りなので、ここではデータベースのレコードをSQLで取り出し、それを使いやすい形に変えて特徴量を作成する特徴量エンジニアリングのテクニックについて触れたい。

特徴量エンジニアリングでは主に質的変数(赤、青、白とかみたいな数字じゃない形)をダミー変数に、量的変数(体重とか身長みたいな数値)を使いやすく変形させるテクがある。

特にダミー変数は離散化とか、いろいろあるので、ここら辺の資料を参考にした。

機械学習レシピ#3

データの前処理だけで競馬は強くなる

特徴量の形を使いやすい形に変えるのも、めっちゃ大事。
今回は可視化した結果の分布が、「0が多く、0以上が少ない」場合、SQLでこんな感じに取り出すのが有効だった。

SUM(case when レコード= 値 then 1 else 0 end) 


逆に分布がバラバラな特徴量は、AVG() で平均をとるか、そのまま使うのが有効だった。

特徴量は検証前に、標準化しないと分類精度がすごい下がるので、データ作成後は必ず標準化した。






モデル構築


モデルは、kaggleで有名なランダムファレストの改良版のxgboostを使った。
特にGBDTの改良手法Dropouts meet Multiple Addtive Regression Trees(DART)とパラメーターチューニングのhyperoptの組み合わせが、xgboostとドンピシャで、かなりいい分析精度になった。

xgboostのパラメータは山ほどあるので、チューニングはベストプラクティスを載せてるサイトがを参考にした。

Parameter_Tuning_XGBoost_with_Example

パラメータチューニングはhyperoptとgridseachを使い分けて、手早くチューニング。

精度検証で、交差検証はもはや当たり前で、さらに複数のアルゴリズムでの検証や、複数の精度指標での試行錯誤も王道らしい。

def objective(params):

    skf = cross_validation.StratifiedKFold(
        train_y, # Samples to split in K folds
        n_folds=5, # Number of folds. Must be at least 2.
        shuffle=True, # Whether to shuffle each stratification of the data before splitting into batches.
        random_state=30 # pseudo-random number generator state used for shuffling
    )

    boost_rounds = []
    score = []

    for train, test in skf:
        _train_x, _test_x, _train_y, _test_y = \
            train_x[train], train_x[test], train_y[train], train_y[test]

        train_xd = xgb.DMatrix(_train_x, label=_train_y)
        test_xd = xgb.DMatrix(_test_x, label=_test_y)
        watchlist = [(train_xd, 'train'),(test_xd, 'eval')]

        model = xgb.train(
            params,
            train_xd,
            num_boost_round=100,
            evals=watchlist,
            early_stopping_rounds=30
        )

        boost_rounds.append(model.best_iteration)
        score.append(model.best_score)

    print('average of best iteration:', np.average(boost_rounds))
    return {'loss': np.average(score), 'status': STATUS_OK}

def optimize(trials):
    space = {'booster':'dart',
         'learning_rate':0.1,
         'n_estimators':1000,
         'sample_type':'uniform',
         'normalize_type': 'tree',
         'objective':'binary:logistic',
         'min_child_weight':1,
         'max_depth':9,
         'gamma':0.0,
         'subsample':0.6,
         'colsample_bytree':0.9,
         'reg_alpha':1e-05,
         'nthread':4,
         'scale_pos_weight':1,
         'seed':27,}
    
    # minimize the objective over the space
    best_params = fmin(
        fn=objective,
        space=space,
        algo=tpe.suggest,
        trials=trials,
        max_evals=10
    )

    return best_params

# parameter tuning
trials = Trials()
best_params = optimize(trials)
print(best_params)

# 損失関数(loss)の計算
print(objective(best_params))






正解率


正解率は


AUCで89.33%



MCCで0.548(-1~1の範囲をとり、1で100%の精度)



特徴量の重要度


評価指標はAUCがよく使われるけど、不均衡データとかより正確な精度判定にはMCCの方がいいっぽい。

from sklearn.metrics import matthews_corrcoef
thresholds = np.linspace(0.01, 0.99, 50)
mcc = np.array([matthews_corrcoef(test_y, pred_y>thr) for thr in thresholds])
plt.plot(thresholds, mcc)
best_threshold = thresholds[mcc.argmax()]
print(mcc.max())

この他にも混合行列とかF値を使ってもいいけど、今回はMCCがF値と同じくらい精密なので、MCCで代用。




特徴量の探索(分析)



特徴量の重要度、そのツリー、xgboostの結果から、u1とu2に有効な特徴量選択。検証からさらに特徴量を探すまで深掘りするケースはサイトでは見つからなかったので、このフェーズが一番大変だった。
とりあえず、一般的な統計手法とか、マーケット分析事例で使われてるっぽい分析手法を漁った。

あとは知識、ひらめき、そしてひたすら根気の作業。
結果、ヒストグラムクラスタリング、次元削減、重回帰分析とかが使えるっぽくて、一番合理的な「重回帰分析」を使った。

重回帰分析は、ある一つの特徴量と、他の複数の特徴量の相関関係を計算してくれる

結果的に、

u1=>海外商品系の特徴量

u2=>満足度が高い特徴量

との相関が高く、これらが重要な特徴量と判断。

結論とその理由とかのロジック的な部分は、他の特徴量との相関関係の数値、仮説の消去法で導いた感じ。ちゃんと根拠つきで




最後に(反省点)



kaggleとかの特徴量エンジニアリングを使った分析は、あらかじめ綺麗な特徴量が用意されていて、分析精度が良ければ終わり、みたいなケースが多い。
けど今回はさらに掘り下げて、重要な特徴量の選択までやった。前例がないとほぼ手探りなので、かなり大変だった。

とりあえず、反省点として

・試行錯誤より頭を使って、作業量を減らす(think more, do less)

・行き詰まったり、停滞したら、一回止まって問題の分解とか本質の問題探しをする。絶対にただタスクをこなすだけのループにはまらないこと
(今回はマッキンゼーの問題解決法の書籍が本質の問題探しにかなり使えた
イシューからはじめよ―知的生産の「シンプルな本質」
世界一やさしい問題解決の授業―自分で考え、行動する力が身につく


・チームで動く以上、進歩確認は随時やる


・データ量が多ければ、少ないデータ量(1万件くらい)で試す


・やることの意思疎通にすれ違いがないようにして、やり直しは極力やらない


・段取り8割


・わからないことは、詳しい人に積極的に、遠慮なく聞くこと

あえてxgboostのDARTみたいなハイリスク・ハイリターンのスキルを使うことで、かなりのスキルが身についたなと実感した。




kaggleのテクニック系の参考資料
top2%の私が教えるKaggleの極意

Kaggleで使われた特徴量エンジニアリングとアルゴリズムまとめ

Djangoでアップロードした画像をCNNで予測し、結果を返すアプリを作ってみた(画像認識、機械学習)

kerasで作った画像分類器に画像を読み込ませ、予測したラベルのidを返すアプリ作った。以前、rubyで作ったことがあるけど、今回はpython専用のフレームワークDjangoを使って作成。

画像分類器にはCNNを使ったので、GPUとか学習のところは割愛して、アプリ作成過程についてだけ書いてみる。

目次
1.アプリについて
2.メインのファイル



1.アプリについて


一通りの動作
デフォルトのホーム画面はこんな感じでシンプル

写真をアップロードして、クリックすると予測した確率が高いトップ3のラベルのidが表示される仕組み


アプリ名と構成ファイル
project名はimage_pred

アプリ名はmyapp

メインの構成ファイルは
・setting.py
・forms.py
・views.py
・index.html
・main.py
・urls.py

いろんなファイルを保存するディレクトリは、必要に応じて作成した。




MVCフレームワークについて

DjangoでのMVCフレームワーク

・Mはmodels.pyでデータベースの操作

・Vはviews.pyで画面の表示を操作

・Cはurls.pyでアクセス関係の操作

今回はデータベースにデータを保存しなくていいので、models.pyは使わない。使うときはマイグレーションが必要で、使えるデータベースは任意に指定可能

便利なパッケージでdjango-cleanupがあった。データベースを使うならpipでインストールして、setting.pyのINSTALLED_APPSに入れておきたい





2.メインのファイル


まずパッケージは
・Pillow
・keras
・tensorflow
opencv

とかをpipでインストール





setting.py
myapp作成後に、INSTALLED_APPSにmyappを追加。

画像をアップロードするので以下のディレクトリを設定
・MEDIA_ROOT
=>サーバから見たメディアルートの絶対パス

・MEDIA_URL
=>メディアファイル公開時のURLのプレフィクス。 メディアファイルのURLは「http://アプリのドメイン+MEDIA_URL+メディアファイル名」

# 一部掲載
import os

# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

SECRET_KEY = '8bdf)xh2b4h!8#g$i0j_9pymjld*ov19!rpfgj2qsi6j)-$t9d'

DEBUG = True

ALLOWED_HOSTS = []


# Application definition

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'myapp' # 追加
]

STATIC_URL = '/static/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
MEDIA_URL = '/media/'






forms.py
ここは画像をアップロードする専用のフォームを作成するファイル
画像専用フォーム「forms.ImageField()」を使用

from django import forms

class PhotoForm(forms.Form):
    
    image = forms.ImageField()






index.html

ここはメインのホーム画面を表示するhtml。主にしてる処理は
・main.pyで予測したラベルのidをhtmlの変数に埋め込み、レンダリング
・フォームの設定
・静的ファイル(CSS)の読み込み
など。htmlにpythonの変数を直接、埋め込めるのがDjangoの強み

{% load static %}
<!doctype html>
<html lang="ja">
<head>
 <meta charset="utf-8">
 <title>img_prediction</title>
 <link rel="stylesheet" type="text/css"
      href="{% static 'myapp/css/style.css' %}" />
</head>
<body>
<p>{{pred}}</p>
<table>
<form action="{% url 'index' %}" method="POST" enctype="multipart/form-data">
   {% csrf_token %}
   {{ form }}
   <tr><td></td><td><input type="submit" value="click" /></td></tr>
</form>
</table>
</body>
</html>


静的ファイルは{% load フォルダ名 %}を記述してCSSを読み込ませた。 staticファルダ内にstyle.cssが保存してある。javascriptとかも同じように読み込める

変数は{{pred}}の部分。

フォームはformタグ内に記述。画像の場合はenctype="multipart/form-data"を指定しなきゃだめ







main.py
コードはkerasで作成した学習済みの画像分類器(CNN)と予測結果を返す処理。学習済みの重み、id用のcsvファイルは専用フォルダを別途作成して、保存してる。

# pred関数のみ
def pred(img_path):
    sess = tf.Session()
    K.set_session(sess)
    cnt=pd.read_csv('/Users/d/image_pred/myapp/weight_dir/cnt.csv')
    cnt=cnt.drop('Unnamed: 0', axis=1)
    cnt=cnt.drop('cnt', axis=1)
    
    
    dic = {}
    for i, v in cnt.iterrows():
        dic.setdefault(v['index'], []).append([v['brand_id'], v['model_id'], v['cate_id']])
    
    x=np.asarray(Image.open(img_path))
    x = cv2.resize(x, (100, 100))
    x = np.expand_dims(x, axis=0)
    image = preprocess_input(x)

    test_model = InceptionResNetV2(include_top=True)
    test_model.load_weights('/Users/d/image_pred/myapp/weight_dir/incep_model.h5')
    test_model.compile(optimizer=SGD(lr=0.01, momentum=0.9, decay=0.001, nesterov=True),
                    loss='categorical_crossentropy', 
                    metrics=['accuracy'])

    y_pred = test_model.predict(image)
    top_k=sess.run(tf.nn.top_k(y_pred,k=3,sorted=True))
    idxs=list(np.reshape(top_k[1],(3,)))
    id=[]
    for idx, v in dic.items():
        if idx in idxs:
            id.append(['brand_id:{} model_id:{}, cate_id:{}'.format(v[0][0], v[0][1], v[0][2])])
    return id





views.py
ここは、index.htmlへのレンダリングなど、表示関連の処理を書くとこ。

主に、get時の処理とpost時の処理を関数に分けて書いてある。get時の処理はアクセス時のデフォルトの画面表示。

post時の処理はアップロードして画像を受け取って読み込み、予測したラベルのidを返す処理を書いてる。
フォームからアップロードされた画像を読み込み予測ラベルを返す処理はmain.pyのpred関数をimportで呼び出す。そして、変数を代入して、その結果をhtmlにレンダリング。画像じゃなきゃエラーを返す仕組み

from django.shortcuts import render, redirect
from django.http import HttpResponse
from django.views.generic import TemplateView
from .forms import PhotoForm
from myapp.main import pred

class MyappView(TemplateView):
   def __init__(self):
       self.params={'pred': 'idx',
                    'form': PhotoForm()}
   def get(self, req):
       return render(req, 'myapp/index.html', self.params)

   def post(self, req):
       form = PhotoForm(req.POST, req.FILES)
       if not form.is_valid():
           raise ValueError('invalid form')

       image = form.cleaned_data['image']
       self.params['pred'] = pred(image)
       return render(req, 'myapp/index.html', self.params)





urls.py

Djangoではプロジェクトの下にいくつもアプリを作れる。Djangoでは「1アプリ=1 url」が基本。つまりurls.pyはアプリを作るたびに、プロジェクトの階層以外にも。アプリ内に必ず一つurls.pyを配置する仕組み




・プロジェクトの階層のurls.py
プロジェクトの階層では全てのアプリのurls.pyを管理するpathを指定する。「アプリのことは各アプリ内のurls.pyに聞け」って意味のinclude()を使う

urlpatterns = [
   path('admin/', admin.site.urls),
   path('myapp/', include('myapp.urls')),]


・各アプリ内のurls.py
各アプリ内ではurlspatternにurlを指定する。引数は3つあって、1つめはhttp://~/myapp/の後に続くアドレス、2つめはアクセスするファイル、3つめは指定したurlの名前


from django.conf.urls import url
from .views import MyappView
urlpatterns = [
              url(r'', MyappView.as_view(), name='index'),
              ]


画像をアップロードするたびにファルダにurlと画像が保存されるようにするには以下を追加。

if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)




動作確認

画像をアップロードしてクリックを押す

すると、予測ラベルのidが表示される。

idを返す超単純なアプリを作成した。Djangoの勉強とアウトプットには、やっぱり基礎を抑えて自分でアプリとか作るのが一番っぽい


参考記事

Django - 画像ファイルのアップロード処理

Django-ファイルアップロード機能の使い方 (基本設定編)

Djangoで、けものフレンズキャラの顔を認識させる(Deep Learning)