DCGANで人間の顔を生成

chainer-DQNでオリジナルゲームはまた今度です.
いや,プログラム自体はもう出来ているんですが,いろいろと説明が大変なので
説明が大変じゃないこちらを先に更新します.

本題
何番煎じかわかりませんが,DCGANで人間の顔画像を生成してみます.
DCGANを知らない人のためにざっくりとした説明をします.

DCGANは,画像などを生成するように学習するニューラルネットワーク技術の一種です.
作る人(Generator)と,作られた画像か本物の画像かを見分ける人(Discriminator)に分かれて学習していきます.
Generatorは,Discriminatorが見分けられないように,本物っぽい画像を作っていきます.

一般人が理解すべきことはこれだけです.
だれでも簡単にできるので,人間の顔だけでなく,いろいろな画像に対して行ってみてはどうでしょう.


既出のものたちを紹介します.
ポケモンなんかはポケモンのようなポケモンじゃないような変な生き物が出来上がってておもしろいですね

顔イラスト http://qiita.com/mattya/items/e5bfe5e04b9d2f0bbd47
イラスト http://qiita.com/rezoolab/items/5cc96b6d31153e0c86bc
間取り画像 http://nextdeveloper.hatenablog.com/entry/2016/02/26/211332
アイドル顔画像 http://memo.sugyan.com/entry/20160516/1463359395
ポケモン http://bohemia.hatenablog.com/entry/2016/08/13/132314
アルファベット http://qiita.com/hitokun-s/items/38c0bdc3245a45fd8c29
家紋 http://qiita.com/yu4u/items/47053a1f3f20e9561823

さて,僕はPFNのmattyaさんが作成したchainer-DCGANをそのまま使わせていただきました.
qiita.com

用意した画像はとりあえず1000枚.OpenCVのfacedetectを利用して収集しました.
もっと用意してあるのでそれで実験したらまたブログを更新します.
画像収集に協力してくれた後輩のMくん,ありがとう!(これが一番たいへん)

まず面白いところから.はじめに出来た画像を貼ってみます.
f:id:butyutyumpa:20161205200548p:plain

怖い!
例えるなら,呪われたトイレに浮かび上がる呪いのシミといった感じです.

次に生成された画像はこちら
f:id:butyutyumpa:20161205200741p:plain
もう顔っぽくなってきた.まだちょっと怖いけど,目,鼻,口,髪があり,輪郭はちゃんとしています.
顔の感じはもう完全につかんできていますね


f:id:butyutyumpa:20161205200747p:plain
なんか色が怖い.プレステゲームの「サイレン」
に出てきそうな感じです.


次は学習がかなり進んだあとの画像
f:id:butyutyumpa:20161205201114p:plain
かなり良い! もうほとんど顔やね.

次は学習が進みすぎたっぽい画像
f:id:butyutyumpa:20161205201244p:plain
顔画像1000枚という少なさが原因なのか,単純に過学習なのか,(たぶん両方で,後者がより影響大)似たような画像で埋め尽くされています.
しかもここまで学習が進んでも,人間が見れば「生成された画像っぽい」と分かってしまいますね.


しかしまあ,1000枚程度の画像でここまでできるなら上出来といったところでしょうか.
プログラムの解説や,もっとデータ数を増やした実験はまた次の記事に.

DQN-Chainerちょっとだけ中身見た その2

ここから下の内容は僕のメモみたいなものです.最期まで読んでも得られるものは少ないです.
最終目標は報酬をいろいろと弄ることですが,ちょっとハードルが高くて断念しています.
RL_glueやALEのソースコードも載せていますが,ライセンスも確認しましたので大丈夫なはずです.
万が一間違ってたら教えてください.


github.com

dqn_classに実装されているメソッドは以下の6つ.



def agent_init(self, taskSpec):

def agent_start(self, observation):

def agent_step(self, reward, observation):

def agent_end(self, reward):

def agent_cleanup(self):

def agent_message(self, inMessage):



それぞれの役割は以下

agent_init……必要なものの定義.self.DQN = DQN_class()で,同一ファイル内のchainerで学習するためのクラスDQN_classを指定している.



agent_start……observation(多分ゲームからもらう情報)を受け取り,4*84*84のstateをself.stateに入れる.

  action, Q_now = self.DQN.e_greedy(state_, self.epsilon)で,行動と現在のQ値を取ってくる.

  return returnActionで,self.DQN.e_greedy(state_, self.epsilon)で決定したactionを返す



agent_step……agent_startと同じく,4*84*84のstateをself.stateに入れる.

  DQNの学習はLearningとEvaluationに分かれていて,Learningのとき,Random行動を入れることがある.

  action, Q_now = self.DQN.e_greedy(state_, self.epsilon)で,agent_startと同様に行動と現在のQ値を取ってくる.

  self.DQN.stockExperienceとself.DQN.experienceReplayで,状態,行動,報酬,次状態などを保存,保存したものから学習(ネットワークの更新)を行う.ココが核.

  Learningならtime+=1する

agent_end……多分,Episodeの最期に実行される.agent_stepから,Episodeの最期では必要のない処理を抜いたものっぽい.

agent_cleanup……ここはパスしている.

agent_message……いろんなメッセージを書く.experiment_ale.pyを実行したターミナルに表示される.


おそらくこれらのメソッドはrl_gllueで環境と実験とRLを繋いで学習を行う上で定義しなければならないものなのでしょう.
あとは,

if __name__ == "__main__":
    AgentLoader.loadAgent(dqn_agent())

dqn_agent()を渡してやります.
AgentLoader.loadAgentとはどんなやつなのでしょう?

from rlglue.agent import AgentLoader as AgentLoader

ここを見る限り,rlglueの機能のようです.
使い方はexperiment_ale.pyを見れば分かるでしょうか.ちょっと見てみます.

print "\n\nDQN-ALE Experiment starting up!"
RLGlue.RL_init()

while learningEpisode < max_learningEpisode:
    # Evaluate model every 10 episodes
    if np.mod(whichEpisode, 10) == 0:
        print "Freeze learning for Evaluation"
        RLGlue.RL_agent_message("freeze learning")
        runEpisode(is_learning_episode=False)
    else:
        print "DQN is Learning"
        RLGlue.RL_agent_message("unfreeze learning")
        runEpisode(is_learning_episode=True)

ここは繰り返し処理の部分です.runEpisodeメソッドが主機能でしょう.
同じくexperiment_ale.pyの

def runEpisode(is_learning_episode):
    global whichEpisode, learningEpisode

    RLGlue.RL_episode(0)
    totalSteps = RLGlue.RL_num_steps()
    totalReward = RLGlue.RL_return()

    whichEpisode += 1

    if is_learning_episode:
        learningEpisode += 1
        print "Episode " + str(learningEpisode) + "\t " + str(totalSteps) + " steps \t" + str(totalReward) + " total reward\t "
    else:
        print "Evaluation ::\t " + str(totalSteps) + " steps \t" + str(totalReward) + " total reward\t "

ん? totalReward = RLGlue.RL_return()ってことは,rewardはどこか別から受け取っているようです.てっきりexperiment_ale.pyで定義しているのだと思っていました.
dqn_agent_nature.pyでもどこかから受け取っています.いったいどこで定義してるのでしょう?
aleから受け取った勝敗数とかが怪しいと思うのですが,とりあえずRL_glueのpython_codecを見に行ってみましょう.
報酬の合計を取ってくるRL_return()を見てみます.

def RL_return():
    reward = 0.0
    doCallWithNoParams(Network.kRLReturn)
    doStandardRecv(Network.kRLReturn)
    reward = network.getDouble()
    return reward

reward = network.getDouble()だそうです.ではnetwork.getDouble()とは?

26 import rlglue.network.Network as Network
...
63         network = Network.Network()

networkというのはrlglue.network.NetworkのNetwork()というやつらしいです.
network.pyを見に行きます.そこにgetDouble()があるはずです.

  84 kDoubleSize = 8
...
  93         self.recvBuffer = StringIO.StringIO('')
...
145     def getDouble(self):
146         s = self.recvBuffer.read(kDoubleSize)
147         return struct.unpack("!d",s)[0]

うーん...つまり,getDouble()ではバッファーから読み取った文字sを数値化して返すということでしょうか?
じゃあバッファーというのは?
一つずつ追いかけていきましょう.
experiment_ale.pyにおいてまずはじめに実行されるRL_init()を見てみます.

101 def RL_init():
102     forceConnection()
103     doCallWithNoParams(Network.kRLInit)
104     doStandardRecv(Network.kRLInit)
105     #Brian Tanner added
106     taskSpecResponse = network.getString()
107     return taskSpecResponse

104 doStandardRecv(Network.kRLInit)に注目します.

 71 def doStandardRecv(state):
 72     network.clearRecvBuffer()
 73     recvSize = network.recv(8) - 8
 74
 75     glueState = network.getInt()
 76     dataSize = network.getInt()
 77     remaining = dataSize - recvSize
 78
 79     if remaining < 0:
 80         remaining = 0
 81
 82     remainingReceived = network.recv(remaining)
 83
 84     # Already read the header, so discard it
 85     network.getInt()
 86     network.getInt()
 87
 88     if (glueState != state):
 89         sys.stderr.write("Not synched with server. glueState = " + str(glueState) + " but s    hould be " + str(state) + "\n")
 90         sys.exit(1)

82 remainingReceived = network.recv(remaining)に注目します.network.recv()を見てみます.

119     def recv(self,size):
120         s = ''
121         while len(s) < size:
122             s += self.sock.recv(size - len(s))
123         self.recvBuffer.write(s)
124         self.recvBuffer.seek(0)
125         return len(s)

122 s += self.sock.recv(size - len(s))に注目します.sockという名前的に,ソケットでしょうか.
僕はBSDソケットのことをよく知らないのですが,やはりaleから受け取っていると見てよさそうです.

 28 import socket

とありました.ググります.
17.2. socket — 低レベルネットワークインターフェース — Python 2.7.x ドキュメント

このモジュールは、PythonBSD ソケット(socket) インターフェースを利用するために使用します。最近のUnixシステム、Windows, Max OS X, BeOS, OS/2など、多くのプラットフォームで利用可能です。


やっぱりBSDソケットでした.RL_glueを動かした時の動きが見たこと無いなあと思ってたらこういうことだったんですね.勉強になりました.

 64         network.connect(host,port)

もう一度experiment_ale.pyにおいてまずはじめに実行されるRL_init()を見てみます.

101 def RL_init():
102     forceConnection()
103     doCallWithNoParams(Network.kRLInit)
104     doStandardRecv(Network.kRLInit)
105     #Brian Tanner added
106     taskSpecResponse = network.getString()
107     return taskSpecResponse

ここのforceConnection()でBSDソケット通信の初期化をしてると思います.

 37 def forceConnection():
 38     global network
 39     if network == None:
 40
 41         theSVNVersion=get_svn_codec_version()
 42         theCodecVersion=get_codec_version()
 43
 44         host = Network.kLocalHost
 45         port = Network.kDefaultPort
 46
 47         hostString = os.getenv("RLGLUE_HOST")
 48         portString = os.getenv("RLGLUE_PORT")
 49
 50         if (hostString != None):
 51             host = hostString
 52
 53         try:
 54             port = int(portString)
 55         except TypeError:
 56             port = Network.kDefaultPort
 57
 58         print "RL-Glue Python Experiment Codec Version: "+theCodecVersion+" (Build "+theSVN    Version+")"
 59         print "\tConnecting to " + host + " on port " + str(port) + "..."
 60         sys.stdout.flush()
 61
 62
 63         network = Network.Network()
 64         network.connect(host,port)
 65         network.clearSendBuffer()
 66         network.putInt(Network.kExperimentConnection)
 67         network.putInt(0)
 68         network.send()

64 network.connect(host,port)
ここだなあ

101     def connect(self, host=kLocalHost, port=kDefaultPort, retryTimeout=kRetryTimeout):
102         while self.sock == None:
103             try:
104                 self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
105                 self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
106                 self.sock.connect((host, port))
107             except socket.error, msg:
108                 self.sock = None
109                 time.sleep(retryTimeout)
110             else:
111                 break

ここでconnectの初期化を行ったみたいです.
いまだrewardへはたどり着けていません.おもったより長い道のりです.
ともあれ,network.recv()の

119     def recv(self,size):
120         s = ''
121         while len(s) < size:
122             s += self.sock.recv(size - len(s))
123         self.recvBuffer.write(s)
124         self.recvBuffer.seek(0)
125         return len(s)

では,ソケットから受け取った文字列をwriteしていることがわかります.size-len(s)はなんなんでしょう?
sizeはどこかから受け取っていますが,len(s)はイミフです.上でs=''とやっているのだから,len(s)は必ず0になるんじゃないんでしょうか?
まあそれは置いといて,sock.recv(size - len(s))がrewardにつながるのかな? sizeの値によりそうですが.
recvを呼んでいるのが,RLglue.pyの

82     remainingReceived = network.recv(remaining)

でした.
remainingとは?
同じくRLglue.pyのdoStandardRecv(state):から

 73     recvSize = network.recv(8) - 8
 74
 75     glueState = network.getInt()
 76     dataSize = network.getInt()
 77     remaining = dataSize - recvSize

remainingはdataSize - recvSizeです.
recvSizeはnetwork.recv(8) - 8です.
つまり,len(s)から8を引くことになります.
len(s)はself.sock.recv(8)を文字列化したもの文字数です.
sock.recv()とnetwork.recv()は似てますが全く別物です.
socketのrecvメソッドはこちら


len(s)-8というのはつまり,文字数から8を引くということです.だめだ.わけわかんなくなってきた.
dataSizeはnetwork.getInt()で取ってきます.
その直前のglueStateもnetwork.getInt()で取ってきています.同じものでしょうか? 見てみます.

 83 kIntSize = 4
 ...
141     def getInt(self):
142         s = self.recvBuffer.read(kIntSize)
143         return struct.unpack("!i",s)[0]

dataSizeは4のようです.
要するにremainingはsocket(8)-8-4でしょうか.

ギブアップです.

報酬の付近をいろいろと弄ってみたかった(たとえば勝ち負けではなくラリーが続くように学習するなど)のですが,ちょっと調べることが多くなりそうです.知ってる方がいたらぜひコメントください.お願いします.
まあぶっちゃけゲームをプレイさせるのはそんなに重要な事ではないので,今回は潔く諦めることにしましょう.

次はDQN-chainerを参考に,自作の簡単なゲームをDQNで学習させてみたいと思います.

DQN-Chainerを動かした&ちょっとだけ中身見た

ラボのサーバーでDQN-Chainerを動かしました.

CUIだと動かないかもと思ったけど動いてます.

途中失敗したっぽかったけどなんとか動いてる.

 

DQN-chainerの記事はこちら.

qiita.com

基本参考にしたインストール手順

hirotaka-hachiya.hatenablog.com

 

後輩が「CUIだと動かないっぽいっすよ」と言っていたので恐る恐るやってたらちゃんと動きました.動いてる様子はこちら.

youtu.be

きもいけどなんとか動いてる.

全然学習できてない段階で動かしてるので右側のDQNちゃんが弱すぎですね.

lab_server@user:~/RL/Arcade-Learning-Environment-0.5.1$ make -j 4 Linking C executable doc/examples/RLGlueAgent Linking C executable doc/examples/RLGlueExperiment
/usr/bin/ld: cannot find -lrlexperiment collect2: error: ld returned 1 exit status /usr/bin/ld: cannot find -lrlagent collect2: error: ld returned 1 exit status make[2]: ***
[doc/examples/RLGlueExperiment] Error 1 make[1]: *** [CMakeFiles/RLGlueExperiment.dir/all] Error 2 make[1]: *** Waiting for unfinished jobs.... make[2]: *** 
[doc/examples/RLGlueAgent] Error 1 make[1]: *** [CMakeFiles/RLGlueAgent.dir/all] Error 2 [ 33%] Built target ale-bin [ 66%] Built target ale-c-lib make: *** [all] Error 2


 

こんなエラーが出て,ALEのインストールの66%の段階で失敗したからもうダメかと思ったけど,ディレクトリにaleっていう実行ファイルがあったからダメ元で動かしたら動きました.

僕の環境だとだいたい15分でEpisode20まで動きました.先駆者によると,Episode700くらいでHandMadeAgentより強くなります.現実的な範囲ですね.
先駆者の動画はこちら
www.youtube.com

簡単に中身を見たのでforwardのあたりだけ解説します.間違ってたらコメントくれるとありがたいです.

まず,DQN-Chainer/dqn_agent_nature.pyから.

ここがネットワーク部分

        self.model = FunctionSet(
            l1=F.Convolution2D(4, 32, ksize=8, stride=4, nobias=False, wscale=np.sqrt(2)),
            l2=F.Convolution2D(32, 64, ksize=4, stride=2, nobias=False, wscale=np.sqrt(2)),
            l3=F.Convolution2D(64, 64, ksize=3, stride=1, nobias=False, wscale=np.sqrt(2)),
            l4=F.Linear(3136, 512, wscale=np.sqrt(2)),
            q_value=F.Linear(512, self.num_of_actions,
                             initialW=np.zeros((self.num_of_actions, 512),
                                               dtype=np.float32))

覚え書きとしてl4の入力が何故3136になるのか書いておきます.
はじめ,AtariのPongというゲームは210*160の画像サイズで入力されますが,諸事情によりそれを84*84にします.
84*84の画像は,l1の畳み込み層によって20*20の画像になります.しかしそんなことはどこにも書いてありません.自分で計算します.
出力画像の幅は,こういう計算式で導き出されます.


out = floor( \frac{( lsize - ksize ) + Psize \times 2 }{stride }) + 1
つまり今回の場合,

 l1out = floor( \frac{( 84 - 8 ) + 0 \times 2 }{4}) + 1 = 20
 l2out = floor( \frac{( 20 - 4 ) + 0 \times 2 }{2}) + 1 = 9
 l3out = floor( \frac{( 9 - 3 ) + 0 \times 2 }{1}) + 1 = 7
 l4in = 7 \times 7 \times 64 = 3136
となります.
ネットワーク構造的には3つの畳み込み層の後,全結合層があり,最後にQ値を計算する全結合層が一つあるという感じです.
DQNは,強化学習であるところのQ学習におけるQ値の更新を,
本来なら価値関数近似を用いて行う部分にニューラルネットワークを用いていると,
なにやらかっこいいことを言っていますが,要するにQ値をCNNで決めてるってだけの話ですね.構造はとっても単純です.
l1のstrideが4なのはおそらくプレイヤーの横幅かボールのどちらかが4ピクセルだからじゃないでしょうか(適当).
strideの設定とか,ksizeの設定とかはなぜこうなっているのかわからないです.理論にもとづいているのか,いろいろ試した結果なのか.

ここがOptimizerの設定です.

        print "Initizlizing Optimizer"
        self.optimizer = optimizers.RMSpropGraves(lr=0.00025, alpha=0.95, momentum=0.95, eps=0.0001)
        self.optimizer.setup(self.model.collect_parameters())

RMSpropGravesを使っています.CNNの学習で,最高の性能を示すと言われているやつです.
lr, alpha, momentum, epsの値は,このPongというタスク(ゲーム)に最適化された値だと見ていいと思います.

ここが,実際にネットワークを使って学習する部分です.

    def forward(self, state, action, Reward, state_dash, episode_end):
        num_of_batch = state.shape[0]
        s = Variable(state)
        s_dash = Variable(state_dash)

        Q = self.Q_func(s)  # Get Q-value

ちょっと古いchainerのバージョンで開発されたっぽい書き方ですが,新しめのchainerでも動きます.(僕の環境だと1.7.0)
state(ゲームの状態)を受け取り,chainerで扱えるようにVariable型にキャストします.
state_dashは次の状態で,これも誰かから受け取っています.
Q_fanc(s)でQ値を計算します.
以下,Q_fancメソッド.

    def Q_func(self, state):
        h1 = F.relu(self.model.l1(state / 255.0))  # scale inputs in [0.0 1.0]
        h2 = F.relu(self.model.l2(h1))
        h3 = F.relu(self.model.l3(h2))
        h4 = F.relu(self.model.l4(h3))
        Q = self.model.q_value(h4)
        return Q

もちろんstateは画像ですので,配列内のすべての値を255で割ってます.

forwardに戻ります.

        # Generate Target Signals
        tmp = self.Q_func_target(s_dash)  # Q(s',*)
        tmp = list(map(np.max, tmp.data.get()))  # max_a Q(s',a)
        max_Q_dash = np.asanyarray(tmp, dtype=np.float32)
        target = np.asanyarray(Q.data.get(), dtype=np.float32)

ここで次状態のQ値の最大値(つまりは期待値)を取り出してますね.
このtargetというのを教師データにするようです.状態をいい方向へ持っていくという目的に沿っていると考えられます.
対戦ゲームを極めた人ならわかると思いますが,プレイヤーは有利状況を作り出し,不利状況をなるべく避けるものです.
ちなみにQ_func_targetはこの部分

    def Q_func_target(self, state):
        h1 = F.relu(self.model_target.l1(state / 255.0))  # scale inputs in [0.0 1.0]
        h2 = F.relu(self.model_target.l2(h1))
        h3 = F.relu(self.model_target.l3(h2))
        h4 = F.relu(self.model_target.l4(h3))
        Q = self.model_target.q_value(h4)
        return Q

です.Q_fancと同じです.
model_target.l1~model_target.q_valueは,

    def target_model_update(self):
        self.model_target = copy.deepcopy(self.model)

と,self.modelをまるごとコピーしたもののようです.

        for i in xrange(num_of_batch):
            if not episode_end[i][0]:
                tmp_ = np.sign(Reward[i]) + self.gamma * max_Q_dash[i]
            else:
                tmp_ = np.sign(Reward[i])

            action_index = self.action_to_index(action[i])
            target[i, action_index] = tmp_

ここで行動とそれに対する報酬(または報酬の期待値)をtargetに入れてるようです.
np.sign(x)は御存知の通り,x<0のとき-1, x=0のとき0, x>0のとき1を返します.
報酬を0~1までにクリッピングしているようです.
それだと報酬の重み付けができないじゃないか! とも思うんですが,これがデフォのようですね.
こっちのほうが学習が進みやすいそうなので,まあ良しとしましょう.
DeepLearningで学習が全然進まないのは悪夢なので,とりあえずこっち優先というのも頷けます.

        # TD-error clipping
        td = Variable(cuda.to_gpu(target)) - Q  # TD error
        td_tmp = td.data + 1000.0 * (abs(td.data) <= 1)  # Avoid zero division
        td_clip = td * (abs(td.data) <= 1) + td/abs(td_tmp) * (abs(td.data) > 1)

        zero_val = Variable(cuda.to_gpu(np.zeros((self.replay_size, self.num_of_actions), dtype=np.float32)))
        loss = F.mean_squared_error(td_clip, zero_val)
        return loss, Q

ここが1番むずいところかもしれません.
ここにTD誤差について詳しく書いてあります.
qiita.com
ぶっちゃけよくわかりません.まあここは改造することもなさそうなので一旦無視します.
2016/09/26 こっち読むと分かりやすかったです
TD誤差学習


forwardだけで結構長くなってしまいましたね.
ここからオリジナルの何かを作るときは,RL_glueの使い方をしっかり勉強する必要がありそうです.
次回はdqn_agentクラスを見つつ,RL_glueの使い方を書いていくことにしましょう.

はじめました

機械学習、主にDeep Learning関連について書いていきます。

たまに小咄や、日常の出来事なども書いていく予定です。

 

これだけだとさすがに味気ないので、さっそく小咄をひとつ。

 

「コリーちゃんがいる」

 

 まずはじめに言っておくのが、これからお話することはすべて本当にあったことであるということです。僕の人生において、一番恐怖を感じた体験と聞かれれば間違いなくこれであると断言します。

 

 中学生の職場体験というのは今もあるのでしょうか? 僕の中学では、3日ほど地元の職場で働くというイベントがありました。

 

 そのイベントは、ある程度希望したところに行けるというシステムで、僕は子供が好きだったので幼稚園を希望しました。

 

 子供というのは凄い人達で、僕がどう振る舞おうか考えているうちにあっという間に仲良くなってしまいました。

 

 驚いたことは他にもあります。給食の量の少なさと、子どもたちの食べる遅さにも相当びっくりしました。おにぎり一個に20分かけて食べる感じです。僕も給食を貰ったんですが、あんまり暇だったので給食を二人分食べてしまいました。

 

 問題はここからです。園庭で遊ぶ時間になった時、僕は鬼ごっこで最初の鬼になりました。追いかけると子供が蜘蛛の子を散らしたように逃げます。地形を良く知ったすばしっこい子どもたちを捕まえるのは至難の業で、一人も捕まえられずに5分ほど経ちました。

 

 砂場の真ん中で、僕に背を向けてピクリとも動かない園児がいたのです。

 

 これはチャンスだと思い、そっと忍び寄ってその子にタッチしました。

 

 驚く子供の顔が見たかったのですが、反応がありません。よく見ると、両手で目を塞いでいました。泣いているというわけでもなく、ただ目を塞いでいたのです。

 

「どうしたの?」

 

 そう優しく聞くと、

 

「コリーちゃんがいるよ」

 

 というのです。

 

「コリーちゃんって何?」

 

「コリーちゃんだよ」

 

 意味不明です。

 

 ダメだこりゃ。他の子をタッチしよう。そう思い、周囲を見渡すと、園庭にいた園児が全員目を塞いでいたのです。

 

 異常な光景でした。あれだけ騒がしかった園庭がウソのように静まり返り、示し合わせたかのように皆、一つの方向を向いて目を塞いでいるというのは。

 

 あまりの驚きにその場を動けなかった僕なのでしたが、先生が、

 

「コリーちゃんはもう行ったよ!」

 

 と大声で言いました。

 

 すると、「コリーちゃん行っちゃった?」「ほんとだ、コリーちゃんもういないよ」とパラパラと聞こえ、また元のように騒がしい園庭に戻りました。

 

「なんだかよくわからないんだけど、一日に一回か二回あるんだよ。『コリーちゃんはもう行った』と誰かが言わないと、ずっとあのままなんだ」と、先生。

 

 妙な遊びがあるもんだと感心して、その時はそれで終わりました。

 

 翌日。同じことが起きました。

 

「コリーちゃんが来たよ」

 

「コリーちゃん、今日は二人だよ」

 

「違うよ、三人いるよ」

 

「いっぱいいるよ」

 

 今日はそういう遊びか。僕も乗ってみることにしました。

 

「本当だ、いっぱいいるね」

 

「お兄ちゃん、大人なのにわかるの?」

 

 目を塞いだまま子供が聞いてきました。

 

「もちろん。ばっち見えてるよ」

 

 そう言った瞬間、園庭が一気にざわつきました。

 

「お兄ちゃんがコリーちゃん見ちゃった」

 

 口々にそう言っていました。

 

 なにかまずいことをしたらしい。そう感じ取った僕は

 

「うそうそ、見てないよ」

 

 ととっさにごまかしたのですが、もう駄目でした。

 

「コリーちゃんはもう行ったよ」

 

 子供の誰かが言いました。そして目を塞いでいた手をどけた子どもたちは一斉に僕を見てきたのです。ただ見るだけではなく、残念なものを見るような目で。僕はありえないほどの恐怖を感じました。

 

 そして、子どもたちは僕に一切かまってくれなくなったのです。

 

 あれだけ人気者だった僕は、一瞬で孤立しました。

 

 もちろん先生に報告しました。どうも子どもたちの例の遊びのルールを破って、嫌われてしまったみたいです、と。

 

 すると先生は、「大丈夫。僕も経験あるけど、明日には元通りだよ」と。

 

 じゃあ、今日は我慢するしか無いのか。僕は園庭を離れ、教室の端っこで座りました。

 

「僕、コリーちゃん信じてないよ」

 

 そう言ったのは、いつの間にか隣にいた男の子でした。

 

「ねえ、コリーちゃんって何なの?」

 

「わかんない。けど、見ちゃだめなんだって」

 

「見たらどうなるの?」

 

「死ぬ」

 

 その子の目は真剣でした。たかが子供の言うこと。なのに僕はどうしようもないくらいに恐怖を感じました。本当に死ぬかもしれない。そう思ってしまうほどに。

 

 僕が何も言えないでいると、男の子は飽きちゃったのか、どこかへ行ってしまいました。

 

 コリーちゃんという謎の存在。見たら死ぬ。そんな強烈な設定を、誰が考えたのだろう。いつからあるものなんだろう。いろんな疑問が頭に浮かびました。気持ちが落ち着いた頃に、僕は先生に聞きに行くことにしました。

 

「先生、コリーちゃんって、いつからあるんですか?」

 

「うーん、僕が来た時にはすでにあったなあ。もう6年も前の話だけど」

 

 そんなに昔からあるなんて。こうなると、設定を考えた子もすでに卒園してしまっています。

 

「気にしないほうが良いよ。慣れれば大したことないし」

 

 そうですね。と、振り返って園庭に向かおうとした時、

 

「あんまり詮索しないほうが良い。コリーちゃんは探られるのが嫌いだから」

 

 先生が言いました。え、と思って先生を見ると、普通の顔をしています。冗談を言ったようではありません。むしろ、不自然なくらいに無表情でした。

 

「コリーちゃんはもう行ったよ」

 

 園庭の子供の誰かの声が聞こえました。

 

 今まさに、コリーちゃんは来ていた。

 

「そういうことだからね」

 

 先生はにこりと僕に笑いかけて来ました。

 

 さっきの無表情が無かったかのように。

 

 

 

 僕が帰る頃には、強めの雨が降っていました。

 

 職場体験というのは普段体験できないことを経験し、学ぶ場です。しかし、これほどまでに奇妙な体験をしているのは僕だけだろうと思いました。

 

 そもそも幼稚園を選んだのは、子供が好きだからという理由を最初に挙げましたが、実はもう一つ理由がありました。

 

 雨の降る日に、公園で泣いていた僕に、若い女の人が声をかけてくれたことがありました。ちょうど、職場体験からの帰り道に降っていた雨のような日です。僕はそのときの優しさに憧れていたのです。だから、子供には優しくというのが僕のポリシーであり、優しくすることで喜びを感じるようになったのです。

 

 僕は、そのお姉さんに声をかけてもらったときのことを思い出していました。

 

 そして、ひとつ疑問に思いました。あの日、何故僕は泣いていたのか。

 

 それを考えた時、答えは案外とすぐに出ました。

 

『コリーちゃんがいるよ!』

 

 その時に思い浮かんだ映像です。僕はコリーちゃんが怖かった。だから泣いていたんです。

 

 コリーちゃんは僕が幼稚園児のころからありました。それをずっと忘れていたんです。そして、何を隠そう、コリーちゃんを考えついたのは僕なのです。ただし、そのときとは設定が大分ちがいました。

 

 コリーちゃんは外国人の女の子です。とてもきれいな髪をした小さな女の子で、名前は僕が適当につけたものです。近所のスーパーマーケットに行った時に見かけた女の子に勝手に名前をつけて、「コリーちゃんを見た」と僕が言ったんです。

 

 金色の髪の女の子なんて居るはずがないと、そんなのは嘘だと決めつけられました。すごく悔しくなりました。悔し紛れに、「コリーちゃんがいるよ!」と大声で叫んだんです。とっさについた嘘に、「見たら死ぬ」という設定を付け加えました。

 

 あのときのコリーちゃんが、まだ残っているなんて驚きです。そういえば、子供というのはどこから教わったのか、鬼ごっこ、氷鬼、ドロケイなどの遊びを知っています。それというのは実は、世代から世代へと受け継がれていくのでしょう。

 

 それにしても、設定を考えた自分が、「コリーちゃんを一度見たから死んじゃう」なんて思い込みをしていたなんておかしな話ではあります。しかし幼稚園児なら、自分で吐いた嘘を本当だと思い込むということも珍しくありません。

 

 家につく頃には、僕はもう全く怖さを感じなくなっていました。

 

 翌日、職場体験の最終日。

 

 子どもたちも初日のように仲良く振る舞ってくれたし、またコリーちゃんは来たけど、もう僕は何もしなかったので何も問題は起きませんでした。謎が解けた今、わざわざ遊びを乱すようなことはするべきではないと思ったからです。

 

 そして何事も無くその日は終わり、とうとう子供たちともお別れのときがやってきました。

 

 泣いている子供も居ました。つられて僕も泣きそうになりました。

 

 幼稚園の玄関から出る時、僕は先生に、コリーちゃんの正体を教えました。「コリーちゃんは実は、僕が作った遊びだったんです。昨日、ようやく思い出しました」

 

 すると先生は、「へえ、そうなのか」と、案外そっけなく返事をしましたが、何かを考え込んでいる様子でした。それはあまり気に留めず、僕は園児の皆に見送られながら学校に向けて歩き始めました。

 

 さよーならーー。と大きい声が聞こえます。僕も振り返って手を振り返しました。すると、泣きそうな顔で一人の女の子が走り寄って来ました。

 

「お兄ちゃん、死なないで」

 

 言うやいなや、その子は泣き出しました。これは困ったぞと思い、僕はその子に真実を教えることにしました。

 

「お兄ちゃんは死なないよ。だって、コリーちゃんなんて本当は居ないんだ。コリーちゃんっていうのは僕がついた嘘なんだよ」

 

 そう言うと、その子は

 

「違うよ。だって、私がコリーちゃんだもん」

 

 はっとしてその子の顔を見ると、不気味な笑みを浮かべて充血した目を僕に向けたまま、右手に持っていた黒い何かを振りかざして僕の顔面を

 

 というところで目が覚めました。

 

 今までの話はすべて、本当に僕が見た夢の話です。

 

 おしまい。