ぽこやかざん雑記

データエンジニア / 下町モルモット / 広島カープファン / 深夜の馬鹿力 / おくやま

Javaで配列、Listの中身をfor文使わずに出力

タイトルの通り。
サクッと中身を確認したいだけの時に、for文を使って1要素ずつループで出力しなくて済むようにするためのメモ。

配列の中身の出力

Arrays.toString に配列を渡し、それを出力

String[] array = new String[]{"apple", "banana", "cherry"};
System.out.println(Arrays.toString(array));

// 出力
[apple, banana, cherry]

Listの中身の出力

これはシンプルに出力してやるだけ

List<String> list = Arrays.asList("apple", "banana", "cherry");
System.out.println(list);

// 出力
[apple, banana, cherry]

Javaの配列とListの相互変換方法

Javaにおいて、配列とListの相互変換をしたい場合のメモ。
配列をListに変換する方法と、Listを配列に変換する方法について記述する。

配列をListに変換

配列をListに変換するためには、java.util.ArraysクラスのasList()メソッドを使用しする。
このメソッドは配列を固定サイズのListに変換する。

String[] array = {"apple", "banana", "cherry"};
List<String> list = Arrays.asList(array);
System.out.println(list.getClass().getName());

// 出力
java.util.Arrays$ArrayList

ただし、注意点として、この方法で作成されたListは固定サイズであるため、要素の追加や削除はできない。
↓参考にさせていただいた記事↓ https://qiita.com/HomMarkHunt/items/c0172bd6c9801c5768d2

Listを配列に変換

Listを配列に変換するには、ListインターフェースのtoArray()メソッドを使用する。
このメソッドには以下の2つの形式がある。

toArray()メソッド

このメソッドは、Object[]型の配列を返す。
配列の具体的な型が明示しない場合に使う。

List<String> list = Arrays.asList("apple", "banana", "cherry");
Object[] array = list.toArray();
System.out.println(array.getClass().getName());

// 出力
[Ljava.lang.Object;

toArray(T[] a)メソッド

このメソッドは、指定した型の配列を引数として受け取り、リストの要素をこの配列に格納して返す。
配列がリストのサイズより小さい場合は、新しい配列が内部で作成される。

List<String> list = Arrays.asList("apple", "banana", "cherry");
// 配列のサイズは0でもリストのサイズに応じて新しい配列が作成される
String[] array = list.toArray(new String[0]);
System.out.println(array.getClass().getName());

// 出力
[Ljava.lang.String;

JavaのArrays.sortとCollections.sortの違い

Javaでは、配列やコレクションをソートするためにArrays.sort()Collections.sort()の2つのメソッドがよく使われる。
たまにどっちのメソッドを使うべきか混乱するので、違いと具体的な使用例についてメモ。

Arrays.sort()

Arrays.sort()メソッドは、Java配列(プリミティブ型やオブジェクト型)をソートするために使用される。
これはjava.util.Arraysクラスの静的メソッドとして提供されていて、配列の要素を自然順序またはカスタムのComparatorを使用してソートする。

使用例: 8x2の2次元配列のソート

以下の例では、8x2の2次元配列を、各行の2番目の要素を基準にソートしている。

import java.util.*;

public class ArraysSortExample {
    public static void main(String[] args) {
        int[][] array = {
            {5, 2},
            {1, 4},
            {3, 1},
            {8, 3},
            {7, 6},
            {6, 5},
            {4, 7},
            {2, 8}
        };

        Arrays.sort(array, Comparator.comparingInt(a -> a[1]));

        for (int[] row : array) {
            System.out.println(Arrays.toString(row));
        }
    }
}

出力

[3, 1]
[5, 2]
[8, 3]
[1, 4]
[6, 5]
[7, 6]
[4, 7]
[2, 8]

Collections.sort()

Collections.sort()メソッドは、Listインターフェースを実装するコレクションをソートするために使用される。
これはjava.util.Collectionsクラスの静的メソッドとして提供されていて、リストの要素を自然順序またはカスタムのComparatorを使用してソートする。

使用例: List<List>のソート

以下の例では、List<List<Integer>>形式のコレクションを、各リストの2番目の要素を基準にソートしている。

import java.util.*;

public class Memo {
    public static void main(String[] args) {
        List<List<Integer>> lst = new ArrayList<>();
        lst.add(Arrays.asList(5, 2));
        lst.add(Arrays.asList(1, 4));
        lst.add(Arrays.asList(3, 1));
        lst.add(Arrays.asList(8, 3));
        lst.add(Arrays.asList(7, 6));
        lst.add(Arrays.asList(6, 5));
        lst.add(Arrays.asList(4, 7));
        lst.add(Arrays.asList(2, 8));

        Collections.sort(lst, Comparator.comparingInt(list -> list.get(1)));

        for (List<Integer> list : lst) {
            System.out.println(list);
        }
    }
}

出力

[3, 1]
[5, 2]
[8, 3]
[1, 4]
[6, 5]
[7, 6]
[4, 7]
[2, 8]

その他の違い

ソートアルゴリズム

Arrays.sort() はデータの型やサイズに応じて異なるソートアルゴリズム(デュアルピボットクイックソート、ティムソートなど)を使用する。

Collections.sort()Java 7以降、内部でArrays.sort()を使用するようになったため、基本的には同じアルゴリズムを使用する。

パフォーマンス

両者の間に大きな違いはない。
ただし、Collections.sort()は内部で配列に変換するステップがあるため、非常に大きなリストではわずかなオーバーヘッドがある可能性がある。

実践的な使用例

実際の使用例を挙げると、Arrays.sort()は単純な配列のソートに用いられ、Collections.sort()はより複雑なオブジェクトのリストやカスタムの比較ロジックが必要な場合に使われることが多い。

Javaの`Comparable`インターフェースとその使用例

とあるクラスのインスタンスが要素となる配列を特定のクラス変数などでソートしたい時のメモ。
その場合はクラスを Comparable インターフェースから実装する。
このインターフェースは、compareTo メソッドをオーバーライドすることでオブジェクト同士の比較が可能。

例として、以下のようなNumberEntityというクラスを考える。

private static class NumberEntity implements Comparable<NumberEntity> {
    int value;

    public NumberEntity(int value) {
        this.value = value;
    }

    @Override
    public int compareTo(NumberEntity other) {
        return this.value - other.value;
    }
}

compareTo メソッドのロジック:

  • このオブジェクトの value が引数として渡されたオブジェクトの value より小さい場合は、0より小さい値を返す
    • sort したらこのオブジェクトは引数として渡されたオブジェクトより前にくる
  • このオブジェクトの value が引数として渡されたオブジェクトの value と等しい場合は、0を返す
  • このオブジェクトの value が引数として渡されたオブジェクトの value より大きい場合は、0より大きい値を返す
    • sort したらこのオブジェクトは引数として渡されたオブジェクトより後にくる

これにより、例えば Collections.sort() メソッドを使って NumberEntity の配列を value の値で並び替えることができる。

実際の使用例

以下は、上述の NumberEntity クラスを使用して、NumberEntityインスタンスを配列として持って並び替える例。

public static void main(String[] args) {
    List<NumberEntity> entities = new ArrayList<>();
    entities.add(new NumberEntity(5));
    entities.add(new NumberEntity(3));
    entities.add(new NumberEntity(8));

    Collections.sort(entities);

    for (NumberEntity entity : entities) {
        System.out.println(entity.value);
    }
    // 出力: 3 5 8
}

Comparableインターフェースを実装することで、オブジェクトの順序を定義し、それを基にして並び替えることが可能。

ローカルDocker上のApache Airflowでgcloudコマンドを使う

GCPApache Airflow のマネージドサービスである、Cloud Composer を運用していると、手元の Dag をわざわざ Composer 環境に Deploy することなく、ローカル環境でサクッと検証したいケースが度々あります。

その中でも、Composer 環境では、Dag の中で BashOperator を使用して gcloud cli コマンド(以下 gcloud コマンド)をデフォルトで利用できますが、ローカルの Docker 上で構築した Airflow 環境で gcloud コマンドを使用するには少し工夫が必要なため、その方法について記載します。

gcloud コマンドを利用する方法を知りたいだけの場合、「Airflow 環境内で gcloud コマンドを実行する」から読んでいただいて構いません。

Docker を使用した Airflow 環境の構築を1から知りたい場合は、最初の「ローカル Docker 上で Airflow 環境を構築」から読んでいただければと思います。

動作環境

MacBook Pro: Apple M1 Pro

ローカル Docker 上で Airflow 環境を構築

環境構築

まずは、ローカルの Docker 上に Airflow 環境を構築します。
(Airflow のバージョンは 2.2.5 とします)

これは、Airflow 公式ドキュメントの Quick Start > Running Airflow in Docker に従って行えば問題ないです。

$ curl -LfO 'https://airflow.apache.org/docs/apache-airflow/2.2.5/docker-compose.yaml'
$ mkdir -p ./dags ./logs ./plugins
$ docker-compose up airflow-init

# Airflow環境起動
$ docker-compose up

全コンテナが正常に立ち上がったことを確認した後に localhost:8080 にアクセスすると、Airflow の Web UI に繋がります。
最初にログイン画面に遷移しますが、認証情報はデフォルトで Username, Password 共に airflow です。

ログインに成功すると、以下のような Dag 一覧画面になるかと思います。

すでに example というタグがついた Dag が複数用意されていますが、これらの Dag をロードしたくない場合は、

docker-compose.yaml 内の AIRFLOW__CORE__LOAD_EXAMPLES'false' にすればロードされなくなります。

Dag 作成

BashOperator を使って「Hello」と出力するタスク、「World」と出力するタスクの2つで構成されたシンプルな Dag (echo_hello_world.py) を作成します。

import pendulum

from airflow import DAG
from airflow.operators.bash import BashOperator

with DAG(
        dag_id='echo_hello_world',
        start_date=pendulum.datetime(2022, 1, 1, tz='Asia/Tokyo'),
        schedule_interval=None,
        catchup=False,
) as dag:
    echo_hello = BashOperator(
        task_id='echo_hello',
        bash_command='echo "Hello"',
    )

    echo_world = BashOperator(
        task_id='echo_world',
        bash_command='echo "World"',
    )

    echo_hello >> echo_world

作成後に Web UI にアクセスすると、echo_hello_world という Dag が表示されています。

あとは、Dag 名のところをクリックし、右上の再生ボタンを押せば Dag が実行されます。

Airflow 環境内で gcloud コマンドを実行する

Dag 作成

新たに gcloud コマンドを実行する exec_gcloud Dag を作成してみます。

import pendulum

from airflow import DAG
from airflow.operators.bash import BashOperator

with DAG(
        dag_id='exec_gcloud',
        start_date=pendulum.datetime(2022, 1, 1, tz='Asia/Tokyo'),
        schedule_interval=None,
        catchup=False,
) as dag:
    check_gcloud_version = BashOperator(
        task_id='check_gcloud_version',
        bash_command='gcloud version',
    )

これを Web UI から実行するとエラーとなります。

gcloud コマンド自体が Airflow 環境にないので当然ですね。

gcloud コマンドをコンテナにインストール

gcloud コマンドを Airflow 環境上の各 Docker コンテナ上に apt-get コマンドでインストールする必要があるのですが、

そのためには、Airflow の公式ドキュメントにも書いてある通りApache Airflow の Docker イメージをベースに独自に Dockerfile を作成しビルドする必要があります。

When you want to run Airflow locally, you might want to use an extended image, containing some additional dependencies - for example you might add new python packages, or upgrade airflow providers to a later version. This can be done very easily by placing a custom Dockerfile alongside your docker-compose.yaml. Then you can use docker-compose build command to build your image (you need to do it only once). You can also add the --build flag to your docker-compose commands to rebuild the images on-the-fly when you run other docker-compose commands.

Dockefile作成

Apache Airflow の Docker イメージをベースに、追加で gcloud コマンドをインストールする Dockerfile を作成します。

FROM apache/airflow:2.2.5

USER root

RUN echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] http://packages.cloud.google.com/apt cloud-sdk main" | tee -a /etc/apt/sources.list.d/google-cloud-sdk.list \
&& curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | apt-key --keyring /usr/share/keyrings/cloud.google.gpg  add - \
&& apt-get update -y \
&& apt-get install google-cloud-cli -y

USER airflow

最後にユーザをairflowユーザに戻すのを忘れないように注意してください。

  • 諸々の Dag は airflow ユーザが実行するため

docker-compose.yaml の書き換え

docker-compose.yaml の以下の部分を、

  image: ${AIRFLOW_IMAGE_NAME:-apache/airflow:2.2.5}
  # build: .

以下のように書き換えます。

  # image: ${AIRFLOW_IMAGE_NAME:-apache/airflow:2.2.5}
  build: .

こうすることで、同じディレクトリにある Dockerfile をビルドして出来上がったイメージを使ってコンテナを立ち上げるようになります。

Airflow 環境の再構築

再度 Airflow 環境を構築します。イメージのビルドも一緒にやってしまいます。

$ docker-compose up --build

再度 Airflow の Web UI にアクセスし、exec_gcloud Dag を実行してみます。

無事、Dag が成功しました。

めでたし!

SkitchとMojaveのショートカットキーがバッティングする

タイトルの通り。
スクリーンショットした画像を即座に加工できる利点があるので、長らくSkitchを愛用しているのだが、その中で一番多用しているショートカットコマンド「十時スナップショット(cmd+shift+5)」が、
macOS Mojaveで新たに拡張されたスクリーンショットのショートカットコマンドとバッティングしてしまったので解決方法をメモ

解決方法はいたって簡単で、
「システム環境設定 -> キーボード -> ショートカット -> スクリーンショット」という順で移動していって、
「cmd+shift+5」のチェックを外せばおうけい

f:id:okuya-KAZAN:20190314001620p:plain

Unity, Python, Deep Q Network Learningを使って自動運転

前回の更新からずいぶんと経ってしまった...
ドワンゴさんの人工知能研究所が開発した超人工生命Life in Sillico(以下LIS)という、
AIエージェントを強化学習で鍛えられるモジュールを使用して、自動運転のエージェントを作成したので、それについてまとめていこうと思う。
このLISというのが本当に素晴らしいモジュールで、Unityの環境上で作成したエージェントを、AIの学習によく使われるPythonを使って鍛え上げることができる。


LISの導入はこちらの記事で取り上げている。

参考にさせていただいたサイト、論文、記事

  1. Life in Silico : http://github.com/wbap/lis
  2. Lillicrap, et al. Continuous control with Deep Reinforcement Learning
  3. https://yanpanlau.github.io/2016/10/11/Torcs-Keras.html

手法

LISのドキュメントを直接見てくれた方が早い気もするが、AIエージェントがどう最適な行動を学んでいくのか(LISのシステム)について記述していく。

距離センサーは入力情報に使っていない

まず、今回作成したエージェントの入力情報だが、距離センサーに頼らず、車載カメラに映る画像のみを入力情報としている。
エージェントは車載カメラに映る画像からコースアウトしたり、障害物と衝突しない行動を選択していく。

Deep Q Network

エージェントを制御するアルゴリズムとして、LISではDeep Q Network(以下DQN)を用いている。
DQNは、環境から与えられた状態を入力として受け取り、車が選択できる各行動の価値(Q値)を出力するニューラルネットワークのこと。
ここでは、車載カメラの映像が状態となる。

強化学習

車の自律走行の学習方法として、強化学習を用いている。
強化学習とは、エージェントが環境との相互作用を通して、得られる報酬の総和を最大にする行動を学習していく学習法のこと。
ここでいう相互作用というのは、

  • エージェントが今どんな状態なのかを環境から受け取る
  • エージェントは状態を基に行動を選択して環境に返す
  • 環境はエージェントから受け取った行動を基に状態を変化させる
  • 環境は新しい状態と行動に対する報酬をエージェントに返す

といったサイクルのことである。 f:id:okuya-KAZAN:20180524144052p:plain
この学習方法で、車はコースアウトしたり、障害物とぶつからない最適な運転技術を学んでいく。
強化学習の詳細については、こちらの記事でまとめている。

学習の流れ

強化学習を用いて、DQNを学習させていく流れは以下のようになる。

  1. 車載カメラの映像をDQNに入力
  2. 行動の選択
    → ε-greedy法と呼ばれる手法で行動を選択する。
    確率εでランダムな行動を選択し、確率1-εで、DQNから出力(各行動のQ値)が最大となる行動を選択する。
    ε-greedy法を採用することで、他に価値の高い行動がないかを探索でき、様々な行動に対する適切なQ値の学習が可能となる。
  3. 選択された行動の評価
    → 2.で選択した行動に対して報酬を与える。
    例えば、コースに沿って走れていたならプラスの報酬、コースアウトしたり障害物と衝突してしまったらマイナスの報酬といった感じ。
  4. 経験の蓄積
    → 「ある状態である行動をとったらどれだけの報酬を得て、環境がどんな状態に変化した」といった経験を、メモリーと呼ばれる場所にストック。
  5. DQNの学習
    → メモリーに記録されている情報からランダムに経験を選び出し、ディープラーニングによってDQNをチューニングしていく。 f:id:okuya-KAZAN:20180524144123p:plain

実験

ここまで理論的なことを述べてきたが、ここからは実際にLISを使ってどんな実験をしたかを書いていく。

実験の目的

実験の最終的な目的としては、実際の車の1/10スケールの「RoboCar1/10(以下RoboCar)」が、研究室内に作成したコースを自律走行できるよう鍛え上げること。

実験の流れ

シミュレーション環境上でDQNモデルを訓練し、訓練されたモデルを実世界に適用させていく。 行った主な実験は以下の3つ。 1. Virtual Simulation →直線しかないシンプルな道路で自律走行できるよう学習。 2. Laboratry Simulation →シミュレーション環境上に、研究室とRoboCarとドライビングコースを作成し、RoboCarが自律走行できるよう学習。 3. RoboCar verification →Laboratry Simulationでの学習モデルを現実世界に適用。

※ 1,2がシミュレーション環境、3が現実世界での学習となる。

Virtual Simulation

環境

一般道路とほぼ同じ道幅の直線道路を用意して、30mごとに障害物を発生させる。
どちらの車線に障害物が発生するかはランダムに決定。

エージェント

一般的な軽自動車をイメージして作成し、DQNの出力は、右に10度ハンドル切る場合のQ値、左に10度ハンドル切る場合のQ値、直進する場合のQ値の3種類とした。

報酬設計

障害物と衝突するかコースアウトした場合、報酬として-1を与え、障害物を避けるごと(30m進むごと)に+1の報酬を与える。

結果

学習過程、学習の結果を動画にまとめて、YouTubeにアップしているので、ぜひご覧ください。

www.youtube.com

Laboratry Simulation

環境

以下の画像のようなコースを作成し、1エピソードにつき1つ、コース上のストレートの部分のランダムな位置に障害物を発生させる。

エージェント

実際のRoboCarと同じエージェントをシミュレーション環境上作成した。
DQNの出力は、右に25度ハンドル切る場合のQ値、左に25度ハンドル切る場合のQ値、直進する場合のQ値の3種類とした。

報酬設計


よりコースの真ん中を、よりコースの軸に沿って進むと多くの報酬がもらえるよう報酬設計した。 コースの軸に対する車体の傾きによる報酬から、|trackPos|による減点をし、最後に車のスピードと比例定数をかけたものを報酬とする。 |trackPos|はコースの真ん中を最小値0とし、真ん中からズレるだけ値が上昇して、コースの端が最大値の1となるよう正規化。

結果

こちらも学習過程、学習の結果を動画にまとめて、YouTubeにアップしているのでぜひご覧ください。

www.youtube.com

RoboCar verification

環境

Laboratory Simulationでの環境と同じになるようドライビングコースを設計した。

エージェント

実際の車の1/10スケールの「RoboCar1/10」をエージェントとした。
こちらもLaboratory Simulation上のAgentと同じプロパティ。

System Architecture

どんなかんじでRoboCarが動くのかを図にまとめる。

ZeroMQというのはネットワークを介して他言語、他OS間でもデータのやりとりができるインターフェース。 Distributed Messaging - zeromq

+αの実験

+αの実験として、信号認識モジュールとの連携を行った。
信号認識モジュールとは同じ研究室の同僚が作成したニューラルネットワークのモジュールで、車載カメラの映像から信号認識を行い、認識結果(Stop, Go)を出力するようチューニングされている。

結果

しつこいようだが、こちらも学習過程、学習の結果を動画にまとめて、YouTubeにアップしているのでぜひご覧ください。

www.youtube.com

まとめ

今回3つの実験を大雑把に書いてしまったが、今後はこれらの実験のより詳細を書き留められたらなと思う次第です。

強化学習,DQNについて自分なりにまとめてみた

強化学習に関連するキーワードや手法についてのメモ。
最終的にはDeep Q-Learning、いわゆるDQNにも触れていこうと思う。

参考にさせていただいた記事、書籍

DQNの生い立ち + Deep Q-NetworkをChainerで書いた - Qiita
ゼロからDeepまで学ぶ強化学習 - Qiita
これからの強化学習 | 森北出版株式会社
正直参考記事、書籍を読めばこの記事はいらないくらいめちゃくちゃ詳しく書いてあるので、ぜひ参考にしてほしい。

強化学習

強化学習とは何かを簡潔にまとめると、「環境に置かれたエージェントが、環境との相互作用を通して、環境に応じた最適な方策を学習する手法」である。

エージェントが環境に行う作用を行動(action)といい、エージェントの行動によって変化する環境の要素を状態(state)という。

エージェントは環境から状態を受け取り、行動を決定し、環境がその行動に応じて変化。
さらに新しい状態をエージェントが受け取る。
このような環境とエージェントの相互作用を繰り返していく。

f:id:okuya-KAZAN:20171019134826p:plain


強化学習は他の機械学習と異なり、教師による答えがない。
その代わり、行動に対する「報酬」というものが存在する。
例えば、ある状態Sで行動A1をとったら報酬+1pt、行動A2をとったら報酬-1ptといった具合だ。

強化学習のキーワード

強化学習を勉強する上で欠かせないキーワードをまとめておく。

エージェント

行動する主体。環境から得られた観測結果をもとに行動を決定する。

環境

エージェントが決定した行動を受けて、次なる状態と報酬を決定し、エージェントに引き渡す。
環境の挙動は設計者の設計対象外。
ただし、報酬に関しては問題設定の一部として設計者が与える必要がある。

時間ステップとエピソード

時間ステップ

エージェントと環境の相互作用における基本的時間単位。1時間ステップの間に、エージェントは環境から状態や報酬を受け取り、それをもとに行動を決定して環境に引き渡す。

エピソード

タスクの開始から終了までの時間。
囲碁であれば碁盤に石がない状態から、試合が終了するまでがエピソードである。

マルコフ性

t+1ステップ目における状態は、tステップ目での状態と行動を使って、
P\bigl(S_{t+1}\mid S_t,A_t\bigr)で定められる。この時S_{t+1}は、S_{t-1}A_{t-1}などには依存せず、S_{t}A_{t}のみに依存して定まる。このように直前の状態のみで遷移確率が決まる性質をマルコフ性と呼ぶ。

方策

状態を入力として、行動を出力する行動決定のための関数。
方策は\piで表し、方策\piのもとである状態sにおけるある行動aが選択される確率を\pi\bigl(a\mid s\bigr)と表す。

最終目標は、エージェントが置かれた状態において、できるだけ多くの報酬得られるよう方策を設計していくこと。
もっと詳しく言うと、環境から得られる「報酬」「状態」から方策を改善していくこと(DQNでは方策の改善 -> NNのパラメータのチューニング)。

報酬、収益

ここでいう報酬というのは、ある行動を取った直後に得られる報酬(即時報酬)のことであるが、即時報酬の大きさだけを行動の指標にしてしまうと局所解に落ち込んでしまう危険性がある。

例えば、無人島に漂着した場合、周囲を探索するより、じっと座り込んでいる方が、体力の消耗が少ないので即時報酬は高い。
しかし、体力を使うという負の即時報酬を得ながらも探索を続けることで、食料を見つけ、結果的にじっと座ってるよりも大きな報酬を得られる可能性がある。

つまり、即時的には小さな報酬しか得られない行動でも、後にとても大きな報酬を得ることに繋がれば、その行動は全体としてみれば良い行動とみなせる。

よって、即時報酬だけでなく、その後に得られる全ての報酬の累積である収益を考える必要があり、強化学習においてエージェントは、「行動の選択を通して得られる収益の総和の最大化」を目的として学習していく。

収益にはそのまま報酬を足し合わせて定義されるものも存在するが、主には、未来にいくに従って割引されて定義されていく割引報酬和で収益が多く採用されているらしい。
割引報酬和とは、報酬を足し合わせる際に、未来の報酬ほど減衰させることで未来の不確実性を表現したものであり、時間ステップtでの収益G_tを、

G_t = \sum_{\tau=0}^{\infty} \gamma^{\tau}R_{t+1+\tau}=R_{t+1}+\gamma R_{t+2}+\gamma^{2}R_{t+3}+\cdots
のように定義する。
ここで\gamma\left(0\le\gamma\leq1\right)は割引率と呼ばれ、どれだけ未来を割引くかを表す定数である。

価値,行動価値関数

エージェントと環境との相互作用の中身は確率的に決定されてしまうため、収益も確率的に変動してしまう値となってしまう。
そのため収益は、方策を設計する際の行動の評価指標としては扱いづらい。

そこで、ある状態sから方策に従って行動を決定していった時に得られる収益の期待値を求め、それを価値と呼び、良い方策を設計する際の指標とする。

期待値をとることで、確率的に変動する報酬の平均的な値を算出することができる。
価値の中でも、ある状態sで行動aを選択した場合の収益の期待値を状態sにおける、行動aの行動価値と呼び、さらに行動価値を求める関数を行動価値関数をとし、

Q^\pi \left(s,a\right)
のように定義する。

行動価値を求める手法

ベルマン方程式

実際に行動価値関数を推定していくためのアルゴリズムの1つである、ベルマン最適方程式について述べていく。

この方程式では、未来の収益の期待値である行動価値関数を算出するために、異なる時刻における行動価値関数の間に成り立つ再帰的な関係を利用している。

ある方策\piの元での行動価値関数Q^\pi \left(s,a\right)は、ある状態sで行動aを選択した場合の収益の期待値であるので、

Q^\pi \left(s,a\right)=\mathbb{E}^\pi\left[G_t|S_t=s,A_t=a \right]
と表せる。
ここで収益は、

G_t = \sum_{\tau=0}^{\infty} \gamma^{\tau}R_{t+1+\tau}=R_{t+1}+\gamma R_{t+2}+\gamma^{2}R_{t+3}+\cdots
と表せるので、

\begin{eqnarray}
Q^\pi \left(s,a\right)&=&\mathbb{E}^\pi\left[\sum_{\tau=0}^{\infty}\gamma^{\tau}R_{t+1+\tau}\mid S_t=s,A_t=a \right] \\
&=&\mathbb{E}^\pi\left[R_{t+1}+\gamma R_{t+2}+\gamma^{2}R_{t+3}+\cdots\mid S_t=s,A_t=a \right]
\end{eqnarray}
となる。

\mathbb{E}^\pi\left[\cdot\right]は線形な演算なので、

Q^\pi \left(s,a\right)=\mathbb{E}^\pi\left[R_{t+1}\mid S_t=s,A_t=a \right]+\gamma\mathbb{E}^\pi\left[R_{t+2}+\gamma R_{t+3}+\cdots\mid S_t=s,A_t=a \right]
とすることができる。

右辺の第一項については、

\mathbb{E}^\pi\left[R_{t+1}\mid S_t=s,A_t=a \right]=\sum_{s'}P\left(s'\mid s,a\right)r\left(s,a,s'\right)
となる。
P\left(s'\mid s,a\right)は状態sで行動aを選択した際に状態s’に遷移する確率を表す状態遷移確率であり、r\left(s,a,s'\right)はその時に得られる報酬である。

続いて第二項は、

\begin{eqnarray}
&\gamma&\mathbb{E}^\pi\left[R_{t+2}+\gamma R_{t+3}+\cdots\mid S_t=s,A_t=a \right]\\
=&\gamma&\sum_{s'}P\left(s'\mid s,a\right)\sum_{a'}\pi\left(a'\mid s'\right)\mathbb{E}^\pi\left[R_{t+2}+\gamma R_{t+3}+\cdots\mid S_{t+1}=s',A_{t+1}=a'\right]
\end{eqnarray}
となる。
このうち\mathbb{E}^\pi\left[R_{t+2}+\gamma R_{t+3}+\cdots\mid S_{t+1}=s',A_{t+1}=a'\right]の部分はQ^\pi\left(s',a'\right)とみることができるので、
第二項については、

\gamma\sum_{s'}P\left(s'\mid s,a\right)\sum_{a'}\pi\left(a'\mid s'\right)Q^\pi\left(s',a'\right)
となる。

これらをまとめると、

Q^\pi\left(s,a\right)=\sum_{s'}P\left(s'\mid s,a\right)\Bigl(r\bigl(s,a,s'\bigr)+\gamma\sum_{a'}\pi\bigl(a'\mid s'\bigr)Q^\pi\bigl(s',a'\bigr)\Bigr)
を得る。この方程式をベルマン方程式という。

ここで\sum_{a'}\pi\bigl(a'\mid s'\bigr)Q^\pi\bigl(s',a'\bigr)を見ると、状態s’にて、選択できる全ての行動a'から期待値を求めていることがわかる。

ベルマン最適方程式

状態s’での行動を方策で選択する代わりに、価値関数の値が最大となる行動a’を選択した場合の期待値を求めると、ベルマン方程式は、

Q\left(s,a\right)=\sum_{s'}P\left(s'\mid s,a\right)\Bigl(r\bigl(s,a,s'\bigr)+\gamma\max_{a'}Q^\pi\bigl(s',a'\bigr)\Bigr)
となる。この方程式をベルマン最適方程式という。

この方程式にはベルマン方程式とは違い、計算に直接的に行動選択確率が含まれていない。
実際にこのような問題を解く際には、全ての状態に対する行動価値関数の表を用意し、表の全ての要素をベルマン最適方程式で計算していく。

Q-Learning

ベルマン最適方程式を使って最適行動価値関数を求める方法は、状態遷移確率が既知であれば最も効率良く最適価値関数を計算することができる。
しかし、強化学習において、エージェントは環境に関して事前の知識を持っていない。
よって、不完全な知識の上で、知識を収集しながら最適な行動を学習していかなくてはならない。
Q-Learningとは、このような不完全な知識の中での探索結果から行動価値関数を近似し、最適方策を学習していく学習手法である。

例えば状態sで行動aを選択して状態s'に遷移した場合、
遷移時に得た即時報酬と、状態s'からずっと最適な行動を選び続けた時に得ることのできる収益の期待値の和を教師(target)として、Q\bigl(s,a\bigl)とtargetの誤差から新たにQ\bigl(s,a\bigl)を更新していく。
ちなみにこのQ\bigl(s,a\bigl)とtargetの誤差をTD誤差という。



\begin{align*}
&target=r\bigl(s,a,s'\bigr)+\gamma\max_{a'}Q^\pi\bigl(s',a'\bigr)\\
&loss=target-Q\bigl(s,a\bigr)\\
&Q\bigl(s,a\bigr)\leftarrow Q\bigl(s,a\bigr)+\alpha\times loss
\end{align*}


行動aによってエピソードが終了した場合、
targetはただ単にr\bigl(s,a,s'\bigr)となる。

エージェントが行動を選択する方法(方策)

先ほどのQ-Learningの説明で、「状態sで行動aを選択」と書いたが、
実際にどのように行動を選択していくか(方策)について述べる。

greedy

まず、これまでの相互作用の結果から、最も価値の高い行動を選択していくという方策がある。これをgreedy方策という。

ε-greedy

greedy方策は際的行動価値観数が既知であれば最適な方策となる。しかし、現実には最適行動価値観数は始めからわかっておらず、最適な行動価値を探索して推定していく必要がある。
その際に、これまでで得られた最も価値の高い行動を選択するだけでは、他の選択肢がどのくらい良い結果をもたらすか知ることができない。

そこで、今までの学習結果を利用するだけでなく、たまにランダムに探索する必要がある。
このように利用と探索を織り交ぜていく手法としてε-greedy方策が存在する。
確率εでランダムに行動し、確率1-εでgreedy方策をとる。

行動価値関数の近似

状態数が少なければ、テーブル関数Q\bigl(s,a\bigr)を作ることができるが、
例えばカメラの画像などを状態として扱うと、状態数が莫大になり、テーブル関数では表現できなくなってしまう。
そこでQ関数をパラメータ\thetaで近似する。

DQN(Deep Q-Network)

以上の手法にDeep Learningの技術を適用したもの。
関数近似ニューラルネットワークを用いて、Q関数を学習させていく。
ひと昔前までの関数近似は人手で構築された特徴を用いるて性能を向上させてきたが、Deep Q-Network(以下DQN)は入力として画面のデータなどの状態のみを用いて、同じ学習アルゴリズムを適用するだけで、囲碁やチェス、昔の家庭用ゲームなど様々なゲームにおいて人間以上の強さを発揮できるようになったことで注目を集めた。

DQNのネットワーク構造

DQNは行動価値関数を推定するための多層ニューラルネットワークである。
エージェントの視界や画面データなど画素情報を入力とするため、ただの多層ニューラルネットワークでなく畳み込みニューラルネットワークが利用されることが多い。
畳み込みニューラルネットワークの詳しい説明は今回省略する。

DQNの出力は行動価値関数Q\bigl(s,a\bigl)である。
つまり行動aの取りうる種類がN通りの場合、DQNはN通りの出力をもつニューラルネットワークとなる。

DQNの学習におけるテクニック

DQNディープラーニングを用いてネットワークのパラメータを更新していくのだが、上手く学習されるよう下記の工夫がなされている。

  • ターゲットの固定化(neural fitted Q)
  • 学習に用いるサンプルの偏りの抑制(Experience Replay)
neural fitted Q

先ほどQ-Learningの説明のところでも述べたが、
DQNのパラメータを更新してくには、TD誤差r_{t+1}+\gamma\max_{a_{t+1}}Q\bigl(s_{t+1},a_{t+1}:\theta_t\bigr)-Q\bigl(s_t,a_t:\theta_t\bigr)を求める必要がある。

第一項のr_{t+1}+\gamma\max_{a_{t+1}}Q\bigl(s_{t+1},a_{t+1}:\theta_t\bigr)ディープラーニングでいうtargetにあたるのだが、
このtargetはパラメータ\thetaに依存するため収束が安定しないとのこと。

そこでtargetにおけるパラメータ\thetaをある時点でのパラメータ\theta^-で固定し、
r_{t+1}+\gamma\max_{a_{t+1}}Q\bigl(s_{t+1},a_{t+1}:\theta^-\bigr)とする。
そしてある程度学習を繰り返した後に、targetとなるQ関数のパラメータ\theta^-を更新する。

Experience Replay

関数近似に多層ニューラルネットワークなどを用いた場合、
「サンプル取得 -> 学習 -> サンプル取得 -> 学習」という学習法だと、どうしても学習が遅くなってしまう。
そこで、サンプル\bigl(s,a,r,s'\bigr)が無限回得られるなら、それらをどのような順番で学習しても最適な価値関数が得られるという特徴を生かして、経験したサンプルを全てメモリーとして記録しておき、学習を行う際はそこからランダムサンプリングして利用するExperience Replayという手法を使う。
この手法は、時系列的に連続であるサンプル間の相関をバラすというメリットもある。

もっと噛み砕いて書くと、
「ある状態でどう行動をとったら、どんな結果を得て、次にどんな状態になった」という経験をエージェントは蓄積していく。
そしてたまった経験から「あの時、右に曲がればぶつからずに済んだんだな」といった風に、ディープラーニングで最適な行動を学習し、ニューラルネットワークをチューニングしていく。

f:id:okuya-KAZAN:20171019135101p:plain


まとめ

強化学習のキーワードをまとめ、最適な行動価値関数を求める手法についてまとめた。
実際にPythonのChainerでDQNの実装もしたので今度改めてブログにまとめようと思う。

Pythonの標準モジュール「argparse」の使い方についてのメモ

コマンドラインからPythonプログラムに引数を渡す際に便利な、標準モジュール「argparse」に関する備忘録。

環境

Mac Book Air : OS X EI Caption(version 10.11.6)
Python 2.7.12 |Anaconda custom (x86_64)|

Pythonプログラムの中身

コマンドラインから引数を受け取るPythonプログラムを以下のような流れで書いていく。

  • argparseモジュールをimport
  • argparseモジュールのArgumentParserクラスを呼び出しパーサーを生成
  • ArgumentParserクラスのadd_argumentメソッドで引数を追加
  • ArgumentParserクラスのparse_argsメソッドで引数の解析

※プログラムは「Parser_practice.py」という名前のファイルで実行していく。

argparseモジュールをimport

これをしないとはじまらないので早速import

import argparse

ArgumentParserクラスを呼び出しパーサーを生成

argparseモジュールのArgumentParserクラスを呼び出す。
このArgumentParserクラスのコンストラクタを使用することで、構文解析の処理を行うパーサーが生成される。

import argparse
parser = argparse.ArgumentParser()

ここから、add_argumentメソッドで引数を追加していくのだが、
実はコンストラクタが呼び出された時点で[-h/--help]という引数がデフォルトで追加されている。
先ほど記述した順番とは異なるが、parse_argsメソッドで引数の解析を行い、実際に[-h]引数を指定してコマンドラインにヘルプを表示させてみる。

import argparse
parser = argparse.ArgumentParser()
args = parser.parse_args()



実行結果
f:id:okuya-KAZAN:20170927155339p:plain

「usage」はプログラムの利用方法であり(後述)、
「optional arguments:」以下には追加されているオプションが表示されるのだが、今表示されているのは、コンストラクタによって追加された[-h],[--help]引数のみである。
さらに、コンストラクタに引数を指定することによって、ヘルプの表示内容を設定することができる。
設定できる引数は以下の通り

  • prog : プログラム名
  • usage : プログラムの利用方法
  • description :「optional arguments」の前に表示される説明文
  • epilog :「optional arguments」後に表示される文字列
  • add_help : [-h],[--help]オプションをデフォルトで追加するか

実際にこれらを指定してみる。

import argparse

parser = argparse.ArgumentParser(
            prog="aaaaaaaaaa", #プログラム名
            usage="bbbbbbbbbb", #プログラムの利用方法
            description="cccccccccc", #「optional arguments」の前に表示される説明文
            epilog = "dddddddddd", #「optional arguments」後に表示される文字列
            add_help = True #[-h],[--help]オプションをデフォルトで追加するか
            )



実行結果
f:id:okuya-KAZAN:20170927155353p:plain

ちなみに、引数のadd_helpをFalseにして実行すると、


f:id:okuya-KAZAN:20170927155409p:plain


[-h]なんて引数はないと怒られエラーとなる。

add_argumentメソッドで引数を追加

続いてArgumentParserクラスのadd_argumentメソッドで引数を追加していく。
こちらのメソッドは、parse_argsメソッドで引数の解析を行う前に使用する。
add_argumentメソッドの引数は以下の通り

  • name or flags : 位置引数もしくはオプション引数の指定
  • help : 引数の説明
  • required : 引数の省略を不可にするか
  • type : 引数が変換される型
  • choises : 引数の選択肢を指定
  • nargs : 引数の数の指定
  • default : 引数がなかった場合に生成される値
  • metavar : メッセージで表示される引数の名前
  • action : 引数の取り扱いの指定

何がなんだかわからなくなりそうなので1個ずつ見ていく。

name or flags

引数が"位置引数"か"オプション引数"かを指定する。

  • 位置引数 : 関数に対して必須となる引数
  • オプション引数 : 与えても与えなくても良く、接頭辞に「-」または「--」を指定する必要がある
位置引数の例

位置引数を受け取るプログラムを以下のように記述する。

import argparse

parser = argparse.ArgumentParser(
            prog="aaaaaaaaaa", #プログラム名
            usage="bbbbbbbbbb", #プログラムの利用方法
            description="cccccccccc", #「optional arguments」の前に表示される説明文
            epilog = "dddddddddd", #「optional arguments」後に表示される文字列
            add_help = True #[-h],[--help]オプションをデフォルトで追加するか
            )
# 引数の追加
parser.add_argument("test", #位置引数
                    help="eeeeeeeeee" #引数の説明)
args = parser.parse_args() # 引数の解析
print args.test

そして、位置引数「test」に値を入れて出力させてみる。


実行結果
f:id:okuya-KAZAN:20170927155420p:plain


[-h]引数でヘルプを表示すると[positional arguments:]以下に位置引数「test」追加されていることがわかる。さらにhelpで指定した内容も表示されている。
最後の実行結果は位置引数を指定しなかったためにエラーとなる。

オプションの例

オプション引数を受け取るプログラムを以下のように記述する。

import argparse

parser = argparse.ArgumentParser(
            prog="aaaaaaaaaa", #プログラム名
            usage="bbbbbbbbbb", #プログラムの利用方法
            description="cccccccccc", #「optional arguments」の前に表示される説明文
            epilog = "dddddddddd", #「optional arguments」後に表示される文字列
            add_help = True #[-h],[--help]オプションをデフォルトで追加するか
            )
# 引数の追加
parser.add_argument("-t","--test", #オプション引数
                    help="eeeeeeeeee" #引数の説明
                    )
args = parser.parse_args() # 引数の解析
print args.test



実行結果
f:id:okuya-KAZAN:20170927155428p:plain


[-h]引数でヘルプを表示すると[optional arguments:]以下に位置引数「test」追加されていることがわかる。
最後の実行結果はオプションtestには値が入っていないため「None」となっている。

required

オプション引数も位置引数のように、省略を不可にすることができる。
それにはadd_argumentメソッドの引数であるrequiredをTrueにする必要がある。

import argparse

parser = argparse.ArgumentParser(
            prog="aaaaaaaaaa", #プログラム名
            usage="bbbbbbbbbb", #プログラムの利用方法
            description="cccccccccc", #「optional arguments」の前に表示される説明文
            epilog = "dddddddddd", #「optional arguments」後に表示される文字列
            add_help = True #[-h],[--help]オプションをデフォルトで追加するか
            )
# 引数の追加
parser.add_argument("-t","--test", #オプション引数
                    help="eeeeeeeeee", #引数の説明
                    required = True #引数の省略を不可にするか
                    )
args = parser.parse_args() # 引数の解析
print args.test



実行結果
f:id:okuya-KAZAN:20170927155440p:plain


オプション引数「-t」を指定しないと「error: argument -t/--test is required」というエラーが出る。

type

引数は何も指定しないとstr型として扱われるが、add_argumentメソッドの引数であるtypeで引数の型を指定できる。
異なる型の引数を指定するとエラーが出る。

import argparse

parser = argparse.ArgumentParser(
            prog="aaaaaaaaaa", #プログラム名
            usage="bbbbbbbbbb", #プログラムの利用方法
            description="cccccccccc", #「optional arguments」の前に表示される説明文
            epilog = "dddddddddd", #「optional arguments」後に表示される文字列
            add_help = True #[-h],[--help]オプションをデフォルトで追加するか
            )
# 引数の追加
parser.add_argument("-t","--test", #オプション引数
                    help = "eeeeeeeeee",#引数の説明
                    required = True ,#引数の省略を不可にするか
                    type = int
                    )
args = parser.parse_args() # 引数の解析
print args.test



実行結果
f:id:okuya-KAZAN:20170927161718p:plain


choices

引数に指定する値を限定したい場合に使う、こちらの引数で指定した値以外を指定するとエラーとなる。

import argparse

parser = argparse.ArgumentParser(
            prog="aaaaaaaaaa", #プログラム名
            usage="bbbbbbbbbb", #プログラムの利用方法
            description="cccccccccc", #「optional arguments」の前に表示される説明文
            epilog = "dddddddddd", #「optional arguments」後に表示される文字列
            add_help = True #[-h],[--help]オプションをデフォルトで追加するか
            )
# 引数の追加
parser.add_argument("-t","--test", #オプション引数
                    help = "eeeeeeeeee",#引数の説明
                    required = True ,#引数の省略を不可にするか
                    type = int,
                    choices = [0,1] #このオプション引数の値は0か1にしか指定できない
                    )
args = parser.parse_args() # 引数の解析
print args.test



実行結果
f:id:okuya-KAZAN:20170927155505p:plain

nargs

引数に配列を指定したい場合に用いる。
nargsで指定する値は、配列の大きさ。
コマンドラインで指定した引数の数と、nargsの値が異なるとエラーが出る。

import argparse

parser = argparse.ArgumentParser(
            prog="aaaaaaaaaa", #プログラム名
            usage="bbbbbbbbbb", #プログラムの利用方法
            description="cccccccccc", #「optional arguments」の前に表示される説明文
            epilog = "dddddddddd", #「optional arguments」後に表示される文字列
            add_help = True #[-h],[--help]オプションをデフォルトで追加するか
            )
# 引数の追加
parser.add_argument("-t","--test", #オプション引数
                    help = "eeeeeeeeee",#引数の説明
                    required = True ,#引数の省略を不可にするか
                    type = int,
                    nargs = 4
                    )
args = parser.parse_args() # 引数の解析
print args.test



実行結果
f:id:okuya-KAZAN:20170927155514p:plain

default

コマンドラインで引数を指定しなかった場合に、オプション引数に格納される値を設定できる。

import argparse

parser = argparse.ArgumentParser(
            prog="aaaaaaaaaa", #プログラム名
            usage="bbbbbbbbbb", #プログラムの利用方法
            description="cccccccccc", #「optional arguments」の前に表示される説明文
            epilog = "dddddddddd", #「optional arguments」後に表示される文字列
            add_help = True #[-h],[--help]オプションをデフォルトで追加するか
            )
# 引数の追加
parser.add_argument("-t","--test", #オプション引数
                    help = "eeeeeeeeee",#引数の説明
                    default = 5
                    )
args = parser.parse_args() # 引数の解析
print args.test



実行結果
f:id:okuya-KAZAN:20170927155522p:plain

metavar

ヘルプやエラーの出力時の引数名はmetavarという項目で変更することができる。

import argparse

parser = argparse.ArgumentParser(
            prog="aaaaaaaaaa", #プログラム名
            usage="bbbbbbbbbb", #プログラムの利用方法
            description="cccccccccc", #「optional arguments」の前に表示される説明文
            epilog = "dddddddddd", #「optional arguments」後に表示される文字列
            add_help = True #[-h],[--help]オプションをデフォルトで追加するか
            )
# 引数の追加
parser.add_argument("-t","--test", #オプション引数
                    )
parser.add_argument("-o","--origin" ,#オプション引数
                    metavar = "My Optional argument"
                    )
args = parser.parse_args() # 引数の解析
print args.test



実行結果
f:id:okuya-KAZAN:20170927155530p:plain

action

今までは引数の後に値を指定して引数に格納していたが、
actionという項目を使うことで、引数の後に値を入力せずとも引数の指定のみで値を格納することができる。

まず、actionで指定できるものの一部を見てみる。

  • store : 今まで通り引数の後に指定された値を格納する。
  • store_const : constキーワード引数で指定された値を格納
  • store_true : 引数の指定があればtrueを格納、なければfalseを格納
  • store_false : 引数の指定があればfalseを格納、なければtrueを格納
import argparse

parser = argparse.ArgumentParser(
            prog="aaaaaaaaaa", #プログラム名
            usage="bbbbbbbbbb", #プログラムの利用方法
            description="cccccccccc", #「optional arguments」の前に表示される説明文
            epilog = "dddddddddd", #「optional arguments」後に表示される文字列
            add_help = True, #[-h],[--help]オプションをデフォルトで追加するか
            )



# 引数の追加
parser.add_argument("-a","--aaa", #オプション引数
                    action = "store"
                    )
parser.add_argument("-b","--bbb", #オプション引数
                    action = "store_const",const=42
                    )
parser.add_argument("-c","--ccc", #オプション引数
                    action = "store_true"
                    )
parser.add_argument("-d","--ddd", #オプション引数
                    action = "store_false"
                    )

args = parser.parse_args() # 引数の解析

print args.aaa
print args.bbb
print args.ccc
print args.ddd



実行結果
f:id:okuya-KAZAN:20170927155540p:plain

さらに以下の設定をすればactionで引数の取り扱いの指定もできる。

  • append : オプション引数にリストを格納し、そのリストに引数の値を格納する。
  • count : キーワード引数の数を格納
import argparse

parser = argparse.ArgumentParser(
            prog="aaaaaaaaaa", #プログラム名
            usage="bbbbbbbbbb", #プログラムの利用方法
            description="cccccccccc", #「optional arguments」の前に表示される説明文
            epilog = "dddddddddd", #「optional arguments」後に表示される文字列
            add_help = True, #[-h],[--help]オプションをデフォルトで追加するか
            )



# 引数の追加
parser.add_argument("-a","--aaa", #オプション引数
                    action = "append"
                    )
parser.add_argument("-b","--bbb", #オプション引数
                    action = "count"
                    )

args = parser.parse_args() # 引数の解析

print args.aaa
print args.bbb
print args.ccc
print args.ddd



実行結果
f:id:okuya-KAZAN:20170927155550p:plain

まとめ

Pythonの標準モジュールのargparseについてほんの一部だけまとめた。
さらにリファレンスを読めば深い知識が得られそうだが、今回は疲れてしまったのでここまで...

Unityでオブジェクトをスクリプトで動かし視界に映る画像をファイル保存

Unity上でエージェントをキー入力で動かし、エージェントの視界に映る映像をPNGファイルとして保存できるようにした。

環境

Mac Book Air : OS X EI Caption(version 10.11.6)
Unity : 2017.1.0f3

流れ

  • エージェントと、エージェントを動かす環境を作成
  • エージェントの視界に映った画像をPNGファイルとして保存するスクリプトの作成

シーンの環境構築

早速Unityを起動し、新しくプロジェクトを作成し、シーン上に環境を作っていく。
まずHierarchy上にて[Create Empty]で空オブジェクトを作成し、名前を「Agent」とする。
f:id:okuya-KAZAN:20170830174158p:plain
そして、Hierarchy上に最初からある「Main Camera」を「Agent」の子オブジェクトにし、名前を「Eye Camera」に変更。
f:id:okuya-KAZAN:20170830174214p:plain
続いて、エージェントが移動する地面を構築する。
シーン上にTerrainを配置し、名前を「Ground」に変更し、適当に隆起させたりテクスチャをつけた。
f:id:okuya-KAZAN:20170830174335p:plain
※地面のテクスチャはProjectビュー上のAssetsフォルダ内で右クリック->[Import Package]->[Environment]と進みインポートした。
f:id:okuya-KAZAN:20170830174355p:plain


Agentを動かす

キー入力を受けつけ、上向き矢印キーでエージェントが前に進み、左右の矢印キーで方向転換、スペースキーでエージェントがジャンプするようなスクリプトを作成する。
スクリプトを作成する前にまず、
「Character Controllerコンポーネント」をAgentオブジェクトに追加しておく。
f:id:okuya-KAZAN:20170830174454p:plain


Projectビュー上にて[Create]->[C# Script]で新規スクリプトを作成、名前をPlayerController.csとし、中身は以下のようにした。


PlayerController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerController : MonoBehaviour {
  CharacterController controller;
  Vector3 moveDirection;

  public float gravity;
  public float speed;
  public float amountRotate;
  public float speedJump;

  void Start () {
    controller = GetComponent<CharacterController>();
    moveDirection = Vector3.zero;
  }

  void Update () {
    //地上にいる場合のみ操作を行う
    if(controller.isGrounded){
      //Inputの検知->0未満となってバックすることを避ける
      if(Input.GetAxis("Vertical") > 0.0f){
        moveDirection.z = Input.GetAxis("Vertical")*speed;
      }
      else{
        moveDirection.z = 0;
      }

      //方向転換
      transform.Rotate(0,Input.GetAxis("Horizontal")*amountRotate ,0);

      //ジャンプ
      if(Input.GetButton("Jump")){
        moveDirection.y = speedJump;
      }
    }
    //毎フレーム重力を加算
    moveDirection.y -= gravity * Time.deltaTime;

    //移動の実行
    Vector3 globalDirection = transform.TransformDirection(moveDirection);
    controller.Move(globalDirection * Time.deltaTime);

    //移動後地面についてるなら重力をなくす
    if(controller.isGrounded){
      moveDirection.y = 0;
    }
  }
}



スクリプトの詳しい解説は割愛する(疲れてしまったので...)
作成したPlayerController.csをAgentオブジェクトに追加し、Inspectorでpublic変数の値を設定。
f:id:okuya-KAZAN:20170830174552p:plain


プレビューを開始するとAgentを動かすことができた。
f:id:okuya-KAZAN:20170830174611p:plain


エージェントの視界に映った画像をPNGファイルとして保存

というわけでこの記事のメインに入っていく。
流れとしてはこんな感じ。
1.ProjectViewでRenderTextureを作成
2.EyeCameraにRenderTextureを設定
3.RenderTextureをTexture2D変換し、さらにPNG画像として保存するスクリプトを作成

RenderTextureを作成し、TargetTextureに設定

RenderTextureとは、カメラで撮影した映像をTextureとして使える機能である。
まず、Projectビュー上にて、[Create]->[Render Texture]でRenderTextureを作成、名前をMyRenderTextureに変更。
続いて、EyeCameraオブジェクトのInspector上にある[Target Texture]に作成したMyRenderTextureを設定。
これでEyeCameraで描画したものはMyRenderTextureに描画されるようになる。
f:id:okuya-KAZAN:20170830174750p:plain


f:id:okuya-KAZAN:20170830174859p:plain


視界を保存するためのスクリプトの作成

Projectビュー上にて[Create]->[C# Script]で新規スクリプトを作成し、名前をSaveImage.csとした。
実際のスクリプトは以下。


SaveImage.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
//File.~を使うため
using System.IO;

public class SaveImage : MonoBehaviour {
  public Camera eyeCamera;
  private Texture2D texture;

  private int photoNumber = 1;


// Use this for initialization
void Start () {
    texture = new Texture2D (eyeCamera.targetTexture.width, eyeCamera.targetTexture.height,
                            TextureFormat.RGB24, false);
                          }
// Update is called once per frame
void Update () {
    //キーボードの「s」を押したら画像を保存
    if(Input.GetKeyDown("s")){
      SaveCameraImage();
    }
  }

  public void SaveCameraImage() {
    // Remember currently active render textureture
    RenderTexture currentRT = RenderTexture.active;
    // Set the supplied RenderTexture as the active one
    RenderTexture.active = eyeCamera.targetTexture;
    eyeCamera.Render();
    // Create a new Texture2D and read the RenderTexture texture into it
    texture.ReadPixels(new Rect(0, 0, eyeCamera.targetTexture.width, eyeCamera.targetTexture.height), 0, 0);
    //転送処理の適用
    texture.Apply();
    // Restorie previously active render texture to avoid errors
    RenderTexture.active = currentRT;
    //PNGに変換
    byte[] bytes =  texture.EncodeToPNG ();
    //保存
    File.WriteAllBytes("/Users/okuyamatakashi/Desktop/Unity/photo/Hoge" + photoNumber + ".png", bytes);
    photoNumber++;
  }
}



まず、視界が描画されたRenderTextureの転送先であるTexture2Dを作成している。

texture = new Texture2D (eyeCamera.targetTexture.width, eyeCamera.targetTexture.height,
                        TextureFormat.RGB24, false);


キーボードの「S」キーを押したら、画像が保存されるようにした。

void Update () {
  //キーボードの「s」を押したら画像を保存
  if(Input.GetKeyDown("s")){
    SaveCameraImage();
  }
}



RenderTextureからピクセル値を取得するには、RenderTexture.activeに取得元のRenderTextureを指定する必要があるらしい。
そして、RenderTexture.activeに指定された映像をキャプチャするReadPixels()で、ピクセルデータを転送。
しかし、このままではピクセル値をスクリプトから参照できないので、targetTexture.Apply()で転送処理を適用している。

// Set the supplied RenderTexture as the active one
RenderTexture.active = eyeCamera.targetTexture;
eyeCamera.Render();
// Create a new Texture2D and read the RenderTexture texture into it
texture.ReadPixels(new Rect(0, 0, eyeCamera.targetTexture.width, eyeCamera.targetTexture.height), 0, 0);
//転送処理の適用
texture.Apply();



Texture2DをPNGに変換し。
フォルダを指定し保存。

//PNGに変換
byte[] bytes =  texture.EncodeToPNG ();
//保存
File.WriteAllBytes("/Users/okuyamatakashi/Desktop/Unity/photo/Hoge" + photoNumber + ".png", bytes);
photoNumber++;

※File.○○を使用するにはスクリプト冒頭に

using System.IO;

と記述する必要がある。


作成したPlayerController.csをAgentオブジェクトに追加し、eyeCamera変数にEyeCameraオブジェクトを設定。
プレビューを開始し、「S」キーを押すと指定したフォルダにエージェントの視界に映る画像が保存されていることが確認できる。
f:id:okuya-KAZAN:20170830175303p:plain


f:id:okuya-KAZAN:20170830175310p:plain