StyleGAN2による画像生成をJSで動かしたい

memo.sugyan.com

ということで 多少集めることができた画像データを使って、StyleGAN2を使って生成を試してみた。

github.com

こんな感じで学習が進み、ある程度はそれっぽい顔画像が生成できるようになってきた。

f:id:sugyan:20200129231329p:plain

これはまだこれから学習データもさらに増やして改善していくとして、それはそれとして こうした生成をWebブラウザ上でも動かせるようにしたい。 と思った。

とにかくやってみないと分からん、ってことで挑戦。

Pickle to SavedModel

まず、学習の過程で保存されるsnapshot は network-snapshot-000080.pkl のような形で 学習中のネットワーク全体を pickle で固めたものとして保存されている。

これを持ってきて解凍してみるところから。 run_generator.py をみると、以下のように読み込んでいる。

from dnnlib import tflib

tflib.init_tf()
network_pkl = 'network-snapshot-000080.pkl'
with open(network_pkl, 'rb') as fp:
    _G, _D, Gs = pickle.load(fp, encoding='latin1')

しかしこれはGPUが使える環境じゃないと この読み込みさえエラーになってしまう。 dnnlib/tflib/network.py__setstate__ でgraphの構築をしていて、その中で fused_bias_act, upfirdn_2d といったcustom opsを使用している。 これらが、デフォルトでCUDAのAPIを使ったimplementationを使用しているから、のようだ。

もしCPU環境でmodelを読み込もうとしたら、これらをどうにかする必要がある。幸い CUDAを使わない実装も用意されているので

-def fused_bias_act(x, b=None, axis=1, act='linear', alpha=None, gain=None, impl='cuda'):
+def fused_bias_act(x, b=None, axis=1, act='linear', alpha=None, gain=None, impl='ref'):

のように引数のdefault impl'ref' に変えてやることで CUDAが使えない状態でも問題なくloadは出来るようになる。

で、ともかくloadできた Gs から inputs, outputs を取得して SavedModel の形式で保存するようにしてみた。

import pickle
import tensorflow as tf
from dnnlib import tflib


builder = tf.saved_model.Builder('savedmodel')
with tf.Graph().as_default() as graph:
    tflib.init_tf()
    network_pkl = 'network-snapshot-000080.pkl'
    with open(network_pkl, 'rb') as fp:
        _, _, Gs = pickle.load(fp, encoding='latin1')

    inputs = graph.get_tensor_by_name(f'Gs/{Gs.input_names[0]}:0')
    outputs = graph.get_tensor_by_name(f'Gs/{Gs.output_names[0]}:0')
    signature = tf.saved_model.build_signature_def(
        {'inputs': tf.compat.v1.saved_model.build_tensor_info(inputs)},
        {'outputs': tf.compat.v1.saved_model.build_tensor_info(outputs)})
    builder.add_meta_graph_and_variables(
        tf.get_default_session(),
        [tf.saved_model.SERVING],
        signature_def_map={
            tf.saved_model.DEFAULT_SERVING_SIGNATURE_DEF_KEY: signature
        })
builder.save()

これで、pklのsnapshotからSavedModelの形式に変換することが出来た、ということになる。

$ saved_model_cli show --all --dir savedmodel

MetaGraphDef with tag-set: 'serve' contains the following SignatureDefs:

signature_def['serving_default']:
  The given SavedModel SignatureDef contains the following input(s):
    inputs['inputs'] tensor_info:
        dtype: DT_FLOAT
        shape: (-1, 512)
        name: Gs/latents_in:0
  The given SavedModel SignatureDef contains the following output(s):
    outputs['outputs'] tensor_info:
        dtype: DT_FLOAT
        shape: (-1, 3, 256, 256)
        name: Gs/images_out:0
  Method name is:

試しにこのSavedModelを読み込んで生成を実行してみる。

import tensorflow as tf
import numpy as np

with tf.Graph().as_default() as graph:
    model = tf.compat.v2.saved_model.load('./savedmodel')

    generate = model.signatures[tf.saved_model.DEFAULT_SERVING_SIGNATURE_DEF_KEY]
    rnd = np.random.RandomState(0)
    z = tf.constant(rnd.randn(1, 512), tf.float32)

    with tf.compat.v1.Session(graph=graph) as sess:
        sess.run(tf.compat.v1.initializers.global_variables())
        print(sess.run(generate(inputs=z)['outputs']))

すると

2020-01-29 23:33:16.173370: E tensorflow/core/common_runtime/executor.cc:642] Executor failed to create kernel. Invalid argument: Conv2DCustomBackpropInputOp only supports NHWC.
         [[{{node Gs/G_synthesis/8x8/Conv0_up/conv2d_transpose}}]]
Traceback (most recent call last):

...

NHWC でしか動かないものがあって でもmodelは NCHW で作られて固められてしまっているのでダメなようだ…

SavedModel to GraphModel

SavedModelそのままではCPU環境では動かせないのだけど、とりあえず気にせずJSのmodelとしてconvertしてみる。もしかしたらoptimizeの過程でそのへんもどうにかしてくれるかも? と期待してみたり。

pip install tensorflowjs して convertをかけてみる。

$ tensorflowjs_converter \
    --input_format=tf_saved_model \
    --output_format=tfjs_graph_model \
    --signature_name=serving_default \
    --saved_model_tags=serve \
    ./savedmodel \
    ./tfjs

山ほど警告が出た後に、エラーで止まる。

...

ValueError: Unsupported Ops in the model before optimization
RandomStandardNormal

どうやら tensorflowjs_converter では RandomStandardNormal などのopsはサポートされていないらしい。困った。 しかし調べてみると --skip_op_check というoptionが用意されていて、サポートしてないopがあってもとりあえずskipしてくれるようだ。

$ tensorflowjs_converter \
    --input_format=tf_saved_model \
    --output_format=tfjs_graph_model \
    --signature_name=serving_default \
    --saved_model_tags=serve \
    --skip_op_check \
    ./savedmodel \
    ./tfjs

...

2020-01-29 23:43:17.764627: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:786] Optimization results for grappler item: graph_to_optimize
2020-01-29 23:43:17.764712: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   debug_stripper: debug_stripper did nothing. time = 0.969ms.
2020-01-29 23:43:17.764717: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   model_pruner: Graph size after: 2022 nodes (-115), 2099 edges (-115), time = 187.757ms.
2020-01-29 23:43:17.764721: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   constant_folding: Graph size after: 1614 nodes (-408), 1691 edges (-408), time = 1340.328ms.
2020-01-29 23:43:17.764725: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   arithmetic_optimizer: Graph size after: 962 nodes (-652), 1655 edges (-36), time = 209.671ms.
2020-01-29 23:43:17.764728: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   dependency_optimizer: Graph size after: 854 nodes (-108), 1453 edges (-202), time = 108.008ms.
2020-01-29 23:43:17.764805: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   model_pruner: Graph size after: 854 nodes (0), 1453 edges (0), time = 141.52ms.
2020-01-29 23:43:17.764815: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   constant_folding: Graph size after: 854 nodes (0), 1453 edges (0), time = 369.232ms.
2020-01-29 23:43:17.764819: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   arithmetic_optimizer: Graph size after: 854 nodes (0), 1453 edges (0), time = 197.42ms.
2020-01-29 23:43:17.764823: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   dependency_optimizer: Graph size after: 842 nodes (-12), 1441 edges (-12), time = 76.729ms.
2020-01-29 23:43:17.764826: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   debug_stripper: debug_stripper did nothing. time = 10.353ms.
2020-01-29 23:43:17.764829: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   model_pruner: Graph size after: 842 nodes (0), 1441 edges (0), time = 123.947ms.
2020-01-29 23:43:17.764993: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   constant_folding: Graph size after: 842 nodes (0), 1441 edges (0), time = 365.53ms.
2020-01-29 23:43:17.765002: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   arithmetic_optimizer: Graph size after: 842 nodes (0), 1441 edges (0), time = 191.759ms.
2020-01-29 23:43:17.765006: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   dependency_optimizer: Graph size after: 842 nodes (0), 1441 edges (0), time = 77.682ms.
2020-01-29 23:43:17.765010: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   model_pruner: Graph size after: 842 nodes (0), 1441 edges (0), time = 129.848ms.
2020-01-29 23:43:17.765013: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   constant_folding: Graph size after: 842 nodes (0), 1441 edges (0), time = 360.265ms.
2020-01-29 23:43:17.765016: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   arithmetic_optimizer: Graph size after: 842 nodes (0), 1441 edges (0), time = 212.4ms.
2020-01-29 23:43:17.765073: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   dependency_optimizer: Graph size after: 842 nodes (0), 1441 edges (0), time = 79.051ms.
2020-01-29 23:43:22.461213: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:786] Optimization results for grappler item: graph_to_optimize
2020-01-29 23:43:22.461239: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   remapper: Graph size after: 842 nodes (0), 1441 edges (0), time = 149.812ms.
2020-01-29 23:43:22.461244: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   constant_folding: Graph size after: 842 nodes (0), 1441 edges (0), time = 370.785ms.
2020-01-29 23:43:22.461248: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   arithmetic_optimizer: Graph size after: 842 nodes (0), 1441 edges (0), time = 187.354ms.
2020-01-29 23:43:22.461251: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   dependency_optimizer: Graph size after: 842 nodes (0), 1441 edges (0), time = 78.03ms.
2020-01-29 23:43:22.461255: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   remapper: Graph size after: 842 nodes (0), 1441 edges (0), time = 136.545ms.
2020-01-29 23:43:22.461258: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   constant_folding: Graph size after: 842 nodes (0), 1441 edges (0), time = 370.468ms.
2020-01-29 23:43:22.461343: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   arithmetic_optimizer: Graph size after: 842 nodes (0), 1441 edges (0), time = 199.41ms.
2020-01-29 23:43:22.461353: I tensorflow/core/grappler/optimizers/meta_optimizer.cc:788]   dependency_optimizer: Graph size after: 842 nodes (0), 1441 edges (0), time = 79.184ms.
Writing weight file ./tfjs/model.json...

$ ls ./tfjs
group1-shard10of29.bin
group1-shard11of29.bin
group1-shard12of29.bin
group1-shard13of29.bin
group1-shard14of29.bin
group1-shard15of29.bin
group1-shard16of29.bin
group1-shard17of29.bin
group1-shard18of29.bin
group1-shard19of29.bin
group1-shard1of29.bin
group1-shard20of29.bin
group1-shard21of29.bin
group1-shard22of29.bin
group1-shard23of29.bin
group1-shard24of29.bin
group1-shard25of29.bin
group1-shard26of29.bin
group1-shard27of29.bin
group1-shard28of29.bin
group1-shard29of29.bin
group1-shard2of29.bin
group1-shard3of29.bin
group1-shard4of29.bin
group1-shard5of29.bin
group1-shard6of29.bin
group1-shard7of29.bin
group1-shard8of29.bin
group1-shard9of29.bin
model.json

何かが出力された。めでたい。

Load GraphModel and Execute

無事(?)にTensorFlow.jsの GraphModel形式にconvert出来たので、ブラウザ上でこれをloadして実行してみる。

const url = 'http://localhost:8000/tfjs/model.json'
tf.loadGraphModel(url).then((model) => {
    const z = tf.randomUniform([1, 512]);
    model.execute(z).print();
}).catch((err) => {
    console.error(err);
})

するとConsoleで以下のようなエラーが。

 TypeError: Unknown op 'RandomStandardNormal'. File an issue at https://github.com/tensorflow/tfjs/issues so we can add it, or register a custom execution with tf.registerOp()

はいはい。確かにサポートしてないopsだからskipしてもらってたヤツだ。

しかしどうやら tf.registerOp() で自分で定義すれば良いらしい。 tf.randomNormal というAPIはあるので、それがそのまま使えるはず。

const randomNormal = (node) => {
    return tf.randomNormal(node.inputs[0].shape);
};
tf.registerOp('RandomStandardNormal', randomNormal);

というのを足してみる。

そうするとそれっぽく実行できる雰囲気になったのだけど、今度は webgl_util.ts 内で

Error: Failed to compile fragment shader.

という現象が起きて失敗…。えー、、なにそれ。。。

webgl backendでダメなのかな、、と tf.setBackend('cpu') してみると、今度は

Error: Error in conv2dDerInput: depth of input (9) must match input depth for filter 512.

と別のエラー。shapeが合わない…? NCHW のものによる弊害がここで出てきてるのかな…?

現状

ここまでで止まってしまっている状態。JSのmodelをloadするところまでは出来ているから もう一息かな〜と思ったのだけど。。。

modelを一度 NHWC の形式に変換してやるとかの処理が必要になるんだろうか… うーん

顔画像生成のためのデータセットを作る

動機

TensorFlowの登場をきっかけに 機械学習によるアイドル顔識別 という取り組みをしていて、3年以上かけてコツコツとアイドルの自撮りを収集してラベルをつけてデータセットを作ってきたけど、 アイドルヲタクはもう辞めてしまって 現場にも全然行かなくなり、卒業・脱退の情報を追いながらラベルを更新していく作業を続ける情熱はすっかり薄れてしまった。 もうアイドル顔識別プロジェクトは終了にしよう、と思った。

しかし折角今まで集めたデータを捨ててしまうのは勿体無い。せめて最後に何か活用できないものか。 と考えて、「画像生成」に再び取り組んでみることにした。

過去に試したことはあったけど、それほど上手くはいっていない。

この記事を書いたのが2016年。 この後の数年だけでもGANの技術はすさまじく進歩していて、今や 1024x1024 のような高解像度の 写真と見分けがつかないくらいの綺麗な顔画像を生成できるようになったらしい。是非とも試してみたいところだ。

目標

PGGAN (Progressive Growing of GANs) や StyleGAN で使われた CelebA-HQ datasetは、1024x1024 サイズの高解像度の画像を 30,000 枚用意して作られているようだ。

今回はそこまでいかなくとも、せめて 512x512 の画像を 10,000 枚くらいは集めたい。

設計の失敗

しかし自分がアイドル顔識別のために収集してラベル付けしたデータセットは、投稿された自撮り画像から顔領域を検出し 96x96 にリサイズして切り抜いたものだけしか保存していなかった。 あまりストレージに余裕が無くケチった運用をしていたため、元の高解像度の画像をクラウド上に残しておくなどをまったくしていなかった。 つまり 96x96 よりも高解像度の顔画像は手に入らない…。

集め直し

DBから候補となる画像URLを抽出

とはいえ、手元には「元画像のURL」「元画像にひもづいた、抽出された顔画像」「顔画像に対するラベル」のデータは残っている。

  • 各アイドルのTwitterから取得した画像情報 1,654,503
  • 自作の検出器で検出して抽出した顔画像 2,158,681
    • そのうち、人力の手作業でラベル付けしたもの 204,791

などが、自分が3年以上かけて続けたアノテーション作業の成果だ。

高解像度のアイドル顔画像データセットを構築するためには、resize & crop する前の元画像を取得しなおして、今度は解像度を保ったままで顔領域を抽出しなおせば良い。

目当ての「アイドルの自撮り顔画像」だけを選別するには、

  • 写真の中に1枚だけ顔が検出されている
    • → 集合写真などではない単独の自撮りで 高解像度で写っている可能性が高い
  • その顔画像が正しくアイドルとしてラベル付けされている
    • → 顔検出されていても誤検出が一定割合で起きているし、認識対象外のラベル付けをしていたりするので、それらを除外する

という条件のものを抽出すればできるはず。

SELECT
    faces.id,
    photos.id, photos.source_url, photos.photo_url, photos.posted_at,
    labels.id, labels.name
FROM faces
    INNER JOIN photos ON photos.id = faces.photo_id
    INNER JOIN labels ON labels.id = faces.label_id
WHERE
    photos.id in (
        SELECT
            photos.id
        FROM faces
            INNER JOIN photos ON photos.id = faces.photo_id
        WHERE faces.label_id IS NOT NULL
        GROUP BY photos.id
        HAVING COUNT(faces.id) = 1
    )
ORDER BY faces.updated_at DESC

こうして、「おそらくアイドルが単独で写っていたであろう元画像」196,455 枚のURLを取得できた。

しかし 画像URLが取得できていても、それを投稿したアイドルさんが卒業・解散などの後にTwitterアカウントが削除されたり非公開になっていたりすると、もうその画像は参照できなくなってしまう。

実際に取得を試みてダウンロードできたのは このうち 132,513 件だった。

ちょうど休眠アカウント削除というのが最近ニュースになった。卒業後に残っているアイドルのアカウントたちはどうなってしまうのだろうか…。今のうちに画像だけでも取得しておくことが出来て良かったのかもしれない。

Dlibによる単一顔検出

さて、高解像度(といっても 900x1200 程度だけど)の アイドルさんたちの画像を入手することが出来た。

以前はここから OpenCVHaar Feature-based Cascade Classifiers を使って顔検出し、その領域を resize & crop してデータとして使っていた。 また、アイドルの自撮りの特徴として「斜めに傾いて写っているもの」が多く検出しづらい問題があり、それを考慮して回転補正をかけて検出するという仕組みを自作していた。

今回も同様の検出をすることになるが、より高精度に また目・口の位置も検出したいというのもあり、ここでは dlib を使ってみることにした。 dlib は OpenCV同様に顔領域を検出できるほか、その顔領域内のlandmarkとして顔の輪郭や目・鼻・口などの位置まで簡単に検出することができる。

やはり斜めに傾いた顔などにはあまり強くないようなので、以前のものと同様に回転補正をかけて検出を試みるといったことは必要そうだった。 ただ今回はそもそも「対象の画像には顔が一つだけ写っている」という仮定で その単一の顔の部分だけ検出できれば良いので 少し処理は簡単になる。

例えば、宇宙一輝くぴょんぴょこアイドル 宇佐美幸乃ちゃん の場合。

まずは画像を回転することによってはみ出して消えてしまう部分がないように 元画像対角線の長さを持つ正方形領域を作って、その中央に元画像を配置する。

def detect(self, img):
    # Create a large image that does not protrude by rotation
    h, w, c = img.shape
    hypot = math.ceil(math.hypot(h, w))
    hoffset = round((hypot-h)/2)
    woffset = round((hypot-w)/2)
    padded = np.zeros((hypot, hypot, c), np.uint8)
    padded[hoffset:hoffset+h, woffset:woffset+w, :] = img

この画像をそれぞれ少しずつ回転させたものを生成し、それぞれに対して顔検出を試みる。 このとき、 fhog_object_detector.run(image, upsample_num_times, adjust_threshold)APIで検出をかけることで、その検出結果の confidence score も取得できるので それらを含めて全パターンの結果を集める。

手元で試した限りでは -48° 〜 +48° で 12°ずつの回転幅で試すのが、多くの回転角を少ない検出試行で網羅できて良さそうだった。

    self.detector = dlib.get_frontal_face_detector()
    self.predictor = dlib.shape_predictor(datafile)

    ...

    # Attempt detection by rotating at multiple angles
    results = []
    for angle in [-48, -36, -24, -12, 0, 12, 24, 36, 48]:
        rotated = self._rotate(padded, angle)
        dets, scores, indices = self.detector.run(rotated, 0, 0.0)
        if len(dets) == 1:
            results.append([dets[0], scores[0], angle, rotated])
    if len(results) == 0:
        self.logger.info('there are no detected faces')
        return


def _rotate(self, img, angle):
    h, w, _ = img.shape
    mat = cv2.getRotationMatrix2D((w/2, h/2), angle, 1.0)
    return cv2.warpAffine(img, mat, (w, h), cv2.INTER_LANCZOS4)

9つのパターンの中でもっとも高いscoreで顔が検出されたものが、おそらく最も正解に近い傾き角度である、として それを採用する。

f:id:sugyan:20200118212320p:plain

この場合はまったく回転しない 0° でも顔は検出されている(score: 0.3265)が、少し傾けた -12°のものの方が 0.5834 と高いscoreになっているので、そちらを仮の回転角として採用する。

その回転後の画像に対して landmark を検出し、左右の目の中央位置を算出する。 正しく回転して真っ直ぐになっていたら目の高さは同じになるはず、それがズレているのなら そのぶんだけまだ少し傾きがある、という考えで、その左右の目の位置座標から atan2 を使ってその微妙な角度を計算する。

    ...

    # Choose the best angle by scores, and then adjust the angle using the eyes coordinates
    results.sort(key=lambda x: x[1], reverse=True)
    det, _, angle, rotated = results[0]
    shape = self.predictor(rotated, det)
    eyel, eyer = self._eye_center(shape)
    d = eyer - eyel
    angle += math.degrees(math.atan2(d[1], d[0]))
    self.logger.info(f'angle: {angle:.5f}')


    def _eye_center(self, shape):
        eyel, eyer = np.array([0, 0]), np.array([0, 0])
        for i in range(36, 42):
            eyel[0] += shape.part(i).x
            eyel[1] += shape.part(i).y
        for i in range(42, 48):
            eyer[0] += shape.part(i).x
            eyer[1] += shape.part(i).y
        return eyel / 6, eyer / 6

f:id:sugyan:20200118212350p:plain

元々の回転角度と 計算した角度を足して、最終的な回転角とする。 この画像の場合は -12 + 0.156403 = -11.843597° の回転でほぼ真っ直ぐの状態になる、と計算された。

その回転角での画像をもう一度生成し、正しく顔とlandmarkが検出されることを確認する。

    ...

    # Detect face and shapes from adjusted angle
    adjusted = self._rotate(padded, angle)
    dets = self.detector(adjusted)
    if len(dets) != 1:
        self.logger.info('faces are not detected in the rotated image')
        return
    shape = self.predictor(adjusted, dets[0])

次に、見切れている部分の補完を行う。 データセットには顔の周辺部分まで含めて切り取って使うことになるので、その周辺部分で画像が切れていたりすると非常に不自然な領域が存在してしまうことになる。

PGGANの手法では 元の画像から鏡面反射した画像を繋げて広げて(mirror padding)、そこから切り取ることで不自然さを和らげているようだ。 同様にやってみる。

    ...

    # Create a large mirrored image to rotate and crop
    margin = math.ceil(hypot * (math.sqrt(2) - 1.0) / 2)
    mirrored = np.pad(
        img,
        ((hoffset + margin, hypot - h - hoffset + margin),
         (woffset + margin, hypot - w - woffset + margin),
         (0, 0)), mode='symmetric')
    rotated = self._rotate(mirrored, angle)[margin:margin+hypot, margin:margin+hypot, :]

f:id:sugyan:20200118212412p:plain

たしかに背景の壁などはそのまま続いているかのように見えて不自然な領域は減りそうだ。

ここから、両目の位置と口の端の位置・その各点間の距離を使って 切り取るべき顔領域の中心座標と大きさを算出している。 論文内の手法では

  • x: 両目の幅 = e1 - e0
  • y: 両目の中心 から 口の中心 の距離 = (e0 + e1) / 2 - (m0 + m1) / 2
  • c: 切り取る中心座標 = (e0 + e1) / 2 - 0.1 * y
  • s: 切り取るサイズ = max(4.0 * x, 3.6 * y)

といった計算でやっているようだ。そのまま使って適用してみる。

    # Calculate the center position and cropping size
    # https://arxiv.org/pdf/1710.10196v3.pdf
    e0, e1 = self._eye_center(shape)
    m0 = np.array([shape.part(48).x, shape.part(48).y])
    m1 = np.array([shape.part(54).x, shape.part(54).y])
    x = e1 - e0
    y = (e0 + e1) / 2 - (m0 + m1) / 2
    c = (e0 + e1) / 2 + y * 0.1
    s = max(np.linalg.norm(x) * 4.0, np.linalg.norm(y) * 3.6)
    xoffset = int(np.rint(c[0] - s/2))
    yoffset = int(np.rint(c[1] - s/2))
    if xoffset < 0 or yoffset < 0 or xoffset + s >= hypot or yoffset + s >= hypot:
        self.logger.info('cropping area has exceeded the image area')
        return
    size = int(np.rint(s))
    cropped = rotated[yoffset:yoffset+size, xoffset:xoffset+size, :]

f:id:sugyan:20200118212441p:plain

いい感じにそれっぽく、正規化された顔画像として切り抜くことが出来そうだ。

こうして 検出器が出来たので、132,513 件のURLから実際にこの方法による検出を試みた。 そこそこ重い処理ではあるものの、手元のMacBookでも数日かけてゆっくり実行し続けた結果 72,334 件ほどの顔画像を収集することができた。

f:id:sugyan:20200119232129g:plain

見切れ領域の多い画像に起こる問題点

こうして見ると良い画像データが揃っているように見えるが、実際には全然そんなに上手くはいかない。

多くの自撮り画像は かなり寄り気味に撮られていて、顔や頭の輪郭まで全部は写っていない場合が多い。 そうするとどうなるか。鏡面反射で補完しても見切れた顔や頭が反射されて映るだけで 結局不自然な画像になってしまう。

例えば前述の例でも、もしもっと寄り気味に撮られていて頭などが見切れていたら…

f:id:sugyan:20200118214632p:plain

という感じになって、顔やlandmarkは確かに検出されるかもしれないけど、頭や他の部分が変な形に繋がってしまっておかしなものになってしまう。

f:id:sugyan:20200119232246g:plain

ちょっとくらいなら問題ないかもしれないけど、流石に目が複数見えてたりするのはヤバそう…

抽出した顔画像を使った生成テスト

とりあえずは変な形になってしまったデータが存在してしまっていても仕方ない、と割り切って、検出して得ることが出来た顔画像を 10,000 件ほど使って生成モデルでの学習を試みてみた。

512 x 512 にリサイズしたものをデータセットとして使い、 StyleGAN を使って何epochか学習してみた。

f:id:sugyan:20200118215620g:plain

確かにアイドルの顔っぽい画像が生成されるが、やはり右上や左上などに鏡面反射した顔が繋がっているような奇妙な形のものが生成されやすいようだ。。

まぁ、そういう画像を含んだものを学習データとして与えてしまっているのでそうなるのは当然の結果ではある。

画像選別と管理のためのWebアプリケーション

となると今度はデータのクリーニングが必要になってくる。

目視で1枚1枚 画像を確認し、「学習データに使える、顔全体がきれいに入っている画像」と「学習データに使いたくない、不自然な画像」を選別することにした。

ローカル環境でデータを管理したくない、自分好みのUIで作業・確認したい、などの理由もあり、例によって管理用のWebアプリケーションを自作した。

Google App Engine 上で動作するよう、画像を Cloud Storage にアップロード、それにひもづく情報を Cloud Firestore に保存 (以前は Cloud Datastore だったけど 次の時代はFirestoreらしい、ということで今回初めて触ってみた)。Frontendを Create React App で作って、SPAから App Engine Go Standard Environment で作った API を叩く形のアプリケーション。自分しか閲覧・操作できないよう Firebase Authentication で認証するようにしている。

各画像に対して Status というフィールドを用意しておき、 Ready, NG, Pending, OK の4つの値をセットできるようにした。初期値はすべて ReadyReady のものをひたすら見ていって、きれいで使えそうなものを OK、ダメそうなものを NG に変更する。判断に迷ったものは Pending に。1, 2, 3 のボタン操作1つで次々スピーディーに更新していけるようにUIを工夫した。

f:id:sugyan:20200119203714g:plain

誤操作も有り得るので NG だからといって削除したりはせず、 NG として残しておく。これが後で役に立った。

こうして選別作業していって、OK になったものだけを抽出して学習データに使えば、きっときれいな画像が生成できるようになるはず…

選別作業効率化へ

作業は1枚1秒程度でサクサク進むが、実際にやってみると NG の画像が非常に多いことが分かった。

やはり多くのアイドルさんは顔までは写していても頭全体まで写るような自撮りをしていることは少なく、それによってmirror paddingされたものはだいたい頭の形がおかしい画像になってしまう。

8,500 枚ほど選別作業してみてようやく OK のものが 1,250 枚ほど。 約7枚に1枚しか現れず、8割以上は NG もしくは Pending にする感じになった。思った以上に NG の山の中から少数の OK を探すのはストレスフルだし効率が悪い。

NG の 頭の形がおかしくなってるような画像なんて誰でも区別できるし機械にでもやらせればええやん…

と思ったので、一次選別するための機械学習モデルを自作することにした。

幸い、 NG にした画像も削除せずに明確に「NG である」とラベル付けした状態で残している。 画像を入力して、「OKNG か」だけを予測する分類モデルを用意した。 画像に写っている人物が誰か、は関係なく、生成用のデータとして OK なものか NG なものか、だけを判別させるモデルとして学習させることになる。

そこまで厳密に精度を求めるものでもないし、適当に TensorFlow Hub からImageNetで学習済みの InceptionV3 を利用して 2 classes の classification のためのmodelとした。

import tensorflow as tf
import tensorflow_hub as hub

IMAGE_SIZE = (299, 299)


def cnn()
    return hub.KerasLayer("https://tfhub.dev/google/imagenet/inception_v3/feature_vector/4",
                          trainable=trainable, arguments=dict(batch_norm_momentum=0.997))


def train():
    labels = ['ok', 'ng']

    model = tf.keras.Sequential([
        cnn(),
        tf.keras.layers.Dropout(rate=0.1),
        tf.keras.layers.Dense(
            len(labels),
            activation='softmax',
            kernel_regularizer=tf.keras.regularizers.l2(1e-4)),
    ])
    model.build([None, *IMAGE_SIZE, 3])
    model.summary()
    model.compile(
        optimizer=tf.keras.optimizers.RMSprop(),
        loss=tf.keras.losses.CategoricalCrossentropy(),
        metrics=[tf.keras.metrics.CategoricalAccuracy()])

データセットとしては、既にラベル付け済みの NG のものを 4,800 件、 OK のものを 1,200 件 抽出して使用した。 それぞれを 4000:800, 1000:200trainvalidation & test に分割。

最初は全結合層を学習させるだけの転移学習だけでどうにかなるかな、と思ってちょっと試してみたけどダメそうだったので、結局ネットワーク全体を学習させるfine-tuningで。 Google ColaboratoryGPU Runtimeで数十分ほど学習。

NG データの方が多いので学習初期は NG に全振りして accuracy 0.8 とかになるけど、だんだん改善していって 40 epochほど進めると 0.947 まで上がった。

最終的な結果としては、学習に使わなかった validation & test セットに対する推論で 以下のようなConfusion Matrixになった。

f:id:sugyan:20200118235824p:plain

OK label に対しては Precision 0.9016, Recall 0.8250 といったところで、まぁそれなりに学習してくれている、という感覚ではある。

実際に、未使用の Ready の画像 1,000 枚に対してこの分類モデルに判別させてみたところ、197 枚が OK として分類された。 それらを目視で確認してみたところ、 そのうち 128 枚が OK になった。 期待したよりは低かったが、これまでは 数枚に1枚しか現れない OK を探し続ける作業だったのが 今後はこの 一次選別されたものから作業すれば 半数以上は OK を選べるので、作業の心理的ストレスは格段に軽減されて効率的になる。

また、今後さらにデータが増えたら この分類モデルも再度学習させることで、さらに高精度に一次選別を進めることが出来るようになることが期待できる。

現状と今後

こうして、現時点で 1,900 枚くらいまでは OK な画像を集めることができた。 もう少し増えたらそれらを使って生成を再度試してみたいところ。

が、全体の枚数と割合で概算すると 今あるすべての収集画像に対して選別してもまだ OK10,000 枚に届かないかもしれない…。 自撮りのキレイなオススメアイドルさんをご存知の方がいらっしゃったら是非とも教えていただきたいところ。。

Repository

Advent of Code 2019 に挑戦している

f:id:sugyan:20191223224220p:plain

Advent of Code というのがある。

https://adventofcode.com/

日本ではまだあまり 知っている人/やっている人 は多くないかもしれない。検索してみても、日本語の紹介記事はこれくらいしか見つからなかった。

Advent of Code の紹介 - Qiita

僕も、去年 元同僚の @ExAdamu に教えてもらうまでは存在すら知らなかった。

どういうものか、っていうのは上に貼った記事でも書かれている通りで、12/1 〜 12/25 まで 毎日1つずつ、プログラミングを使うパズル問題が出題される、というもの。 puzzle input の入力値が与えられ、それに対する回答を自分の書いたコードで計算し、出力値を submitして正解すれば星が貰える。

入力値とそれに対する正解はどうやらユーザごとに異なるものになっているようで、誰かに正解を訊く みたいなものは出来ないようになっている。 重要なのは正解に辿りつくためのコード、ということになる。

問題は毎日 part1 / part2 と分かれていて、part1はだいたい問題文に書いてある通りに正しく実装すれば答えが出せるような感じになっている。 part2 は、使う入力値はpart1と一緒なのだけど 求められるものがより複雑になり、ちょっと難しくなる。それなりに正しくアルゴリズムとデータ構造を駆使しないと解けなかったり、ある程度は数学的な知識が必要になってきたりするようだ。

すべての日程で part1 / part2 すべて正解すれば星が50個集まる、ということになる。

去年は存在を知っただけで全然挑戦していなかったのだけど、今年はちょっと腕試しと練習を兼ねて、ということで Rustで挑戦してみることにした。 現時点で 23日まで出題されていて、46個中41個まで星を集めた状態。

GitHub - sugyan/adventofcode

別にこれはコンテストとか競技のものではないので 他の人の回答方法を見てはいけないわけではない(早解き上位を目指す人とかは別だろうけど)。

出来る限り自分で考えてみて、分からなかったら reddit で他の人のコードや考え方を見ても良い。 僕も幾つかは詰まってredditの他の人のアイデアを参考にさせてもらったりもした。

とにかく、やってみると、これはとても面白い。

問題がとてもよく出来ていて、スッキリ解けたときの爽快感がすごい。

あと特徴として、毎回恒例なのか今年のが特別なのかは知らないけど シリーズものになっている問題もある。 奇数日は IntCode という 整数値列を使ったレジスタマシンのようなものを使用することになり、このインタプリタを実装する問題が day5, day7, day9 あたりで出されている。このへんは順番にやっていないと解けない。

でも ちゃんと動かせると迷路を出現させたりブロック崩しのゲームが動いたりして、これは感動した

偶数日は逆に他の日とは全然関係ないので まったく予備知識なくても挑戦して解くことが出来るはず。


プログラミングが好きな人や数学が好きな人、是非とも挑戦してみると良いんじゃないかな、と思います。


僕も今年どこまで出来るか分からないけどもうちょっと頑張ってみるし、来年もあったら絶対また挑戦してみたいと思っている。

ISUCON9 本選12位だった

ISUCON9 予選敗退した って書いたんだけど、アレがアレして色々あって、やっぱり本選に出場できることになったので参加してきた。

「失敗から学ぶISUCONの正しい歩き方」チーム、最終結果は12位でした。

言語は予選のときと同じRubyで挑戦。

10:09 初期状態 → 0

初期実装の状態では遅すぎてスコアが出ない、というところからスタート。当然ながらRuby実装に切り替えてもスコアは出ない。

まずはせめてスコアが出るくらいにはしてスタート地点に立たないとね、ってことで諸々サーバ側の準備を進めたりマニュアル読み込んだりしながら 最低限あるべきindex貼って対応していこうね、と作業開始

11:34 ようやくスコア出る → 1,429

さらにindexを貼っていくと、今度はFailするようになる。 実際にブラウザから確認してみると、どうもindexを貼ることによって検索結果の順番が変わってしまうらしい。 primary keyもORDER BY指定もなくて順番が保証されてないやん… 有り得ない、なんだこのクソアプリは!と憤りつつも ORDER BY でそれっぽい順番で返すようにしようとして train_name を指定し、今度はこれが数値に見せかけた文字列だったために 1 の次に 10, 100 が来るというバグを踏み抜き タイムロス。

13:46 ようやくバグが直った → 4,478

一通りindex貼り終わったものが動いて どうにか安心。 そういえば AVAILABLE_DAYS ってのがあるんだよね、ってことで ためしに 1030 に上げてみたところスコア変わらず。 うーん、じゃあ 90 だとどう? → 5,900 まで上がるが500errorなどが出てキツそう。とりあえず 90 で進めることにする。

14:47 複数台構成 → 6,517

id:Soudai さんにお任せしてMySQLをdockerから剥がしてもらって appサーバとdbサーバで分ける構成が動いた。負荷が分散されてスコアが上がる。たまたまこの瞬間で暫定1位の記録になったので慌ててスクショを撮った。

f:id:sugyan:20191007234341p:plain

15:15 get_available_seats 軽減 → 6,672

唯一と言える、僕がまともにコード書いた部分。 /api/train/search 内のN+1はとても複雑で、完全に排除するのは難しそうだったが 最も処理が重くて負担になっているのは get_available_seats を 「premium か否か」「is_smokingか否か」で計4回呼んでいる部分。この中で多数のJOINなどもあるし せめて1回の呼び出しで結果を取得して コード内でカウントすれば多少は早くなるだろう、と id:kamipo さんの提案を受けて実装。

https://github.com/soudai/isucon9-final/pull/5/files

特にバグも無く動いたは動いたが、残念ながらスコアにはあまり寄与しなかった。

そことは別に、なんか benchmarkerの /api/train/search リクエストで 500 errorを多く返していて、ログを見ると departurearrival が検索できず nil になるのにその値を参照しようとしてぬるぽで落ちているものが多数あって、かと言ってそれを応答から消したりエラー握り潰して誤魔化したりしてもどうやってもbenchmarkerに怒られて、どうしようもなくてハマり続けた。これは出題側のバグなんじゃないかなぁとボヤきながら色々試したが結局最後まで解決できず。悔いが残る。

15:39 deadlockの発見

このへんでbenchmarkerがFailしまくるようになり、どうも POST /api/user/reservations/*/cancel で500 errorを頻発しているようだが これも複雑な処理なので 500を返している箇所も複数あり 詳しい原因がよく分からない。

予選のときに得た教訓で、「泥臭いprintデバッグでも活用する」という精神で 画期的なcommitを入れた。

Add 500 error · soudai/isucon9-final@a8afb24 · GitHub

これにより journalctlcancel error!!!!!!!!! 6 を発見し deadlockによるものだというのがすぐに判明し 解決をkamipoさんに丸投げすることが出来た。 これが今回の最大の貢献だったかもしれない……

16:01 deadlock解決(?) → 12,056

これでようやくそこそこのスコアになったが

そこから先 細かいチューニングをするも 結局これより高いスコアが出ることもなく 最後の数十分はひたすら AVAILABLE_DAYS の数値を上げたり下げたりしてFailしないギリギリくらいに…と調整するくらいになってしまった。

しかし 90180くらいで試していて 初期の 10 付近の数値では試そうともしていなかった… もしかしたら敢えてこの数字を下げることでもっと良いスコアが出たかもしれなかったのかなぁ

18:00 競技終了 最終スコア 7,462

10位以内くらいには入りたかったが そこまでもいけなかったなぁ…

反省点

予選のとき以上にコードでの貢献が出来なかった。 1つの処理で何百行もあって複雑なloopのnestがあるような、言ってしまえばクソコード的なものを前に しっかり内容を読み取ってキレイにリファクタリングしていくだけの力が無かった。

どちらかというと方針の相談や 他のメンバーが詰まりそうになったときに横から一緒にみて解決する、みたいな役回りになってしまっていた。それはそれで貢献だったとは思うけど、それだけではどうしても手が足りないし 自分の役目をもっと果たすように動くべきだった、のかもしれない。

POST /api/user/reservations/*/cancel のところは最後まであまり解決できなかったのだけど、外部リクエストの部分を非同期化して まず200を返してしまってから後でゆっくり処理する、などの解法を聞いて、その発想は全然なかったなぁと思った。

感想

同じRubyで挑戦した白金動物園チームが見事に優勝して感動があったし、1人参加の学生さんが2位で驚愕だったし、多くの刺激を受けて とても楽しめたISUCONでした。

今年も本当にありがとうございました!

参照

soudai.hatenablog.com

TensorFlow 2.0 時代の Keras API での画像分類器

TensorFlowを初期の頃から触っていて define-and-run の流儀にはそれなりに慣れてしまっていたけど、そろそろTensorFlowも2.0がreleaseされそうだし(2019.09時点で 2.0rc1) 新しいinterfaceも触っておかないと、と思って勉強してみた。

Effective TensorFlow 2.0 を読むと、major changesとして "Eager execution"、recommendationsとして"Keras layers and models"が紹介されている。 これからの時代はKeras APIを使ってEager executionでやっていく必要がありそうだ。

お題: 将棋駒画像の分類

昨年くらいから将棋の画像認識をやろうと思って 駒の画像データセットを作成 していた。今回はこれを使う。

各駒14種の先手・後手で28種、空白マスを加えて計29 classesを対象として、各classにつき約200〜300枚くらいずつ 96x96 のカラー画像 をラベル付きで用意してある。

f:id:sugyan:20190916230405p:plain

datasetの準備

ラベル付きの画像たちを一定の割合で training, validation, test のdatasetに分割する。 今回は約 8:1:1 で分割して、

  • training: 6277
  • validation: 816
  • test: 745

のdatasetが用意できた。

後述する tf.keras.preprocessing.image.ImageDataGenerator で使いやすいよう、各datasetを各label毎のディレクトリ以下に展開。

dataset
├── test
│   ├── BLANK
│   │   ├── 0de35ef1668e6396720e6fd6b22502b9.jpg
│   │   ├── 15767c0eb70db908f98a9ac9304227c8.jpg
│   │   ├── 18b0f691ac7b1ba6d71eac7ad32efdd5.jpg
│   │   ...
│   ├── B_FU
│   │   ├── 01aad8b7a32ca76e1ed82d72d9305510.jpg
│   │   ├── 04bc08425fb859f883802228e723c215.jpg
│   │   ├── 080950c64fb64840da3835d67fb969b8.jpg
│   │   ...
│   ├── B_GI
│   ...
├── training
│   ├── BLANK
│   ├── B_FU
│   ...
└── validation
    ├── BLANK
    ├── B_FU
    ...

transfer learning

まずは簡単に、学習済みの MobileNetV2 のモデルを使った転移学習をしてみる。

tf.keras.applications packageにはpre-trained modelが幾つか同梱されているので、それを呼び出すだけで簡単に使うことができる。

TensorFlow Hub にも同様に学習済みモデルが公開されていて再利用できるのだけど、現状では TensorFlow 2.0 向けのものは少なくて、 MobileNetV2 のものは input size が 224x224 に制限されているなどでちょっと使いづらい。 今後もっと整備されていくのかもしれないけど 今回は tf.keras.applications.MobileNetV2 を使うことにする。

INPUT_IMAGE_SIZE = (96, 96)

tf.keras.applications.MobileNetV2(
    input_shape=INPUT_IMAGE_SIZE + (3,),
    include_top=False,
    pooling='avg',
    weights='imagenet')

input_shapeのサイズは 96, 128, 160, 192, 224 のどれかで指定できて(defaultは224)、それに応じたimagenetでの学習済みモデルがダウンロードされて使われるようだ。

optionを指定しないと1000 classesの分類用logitsが出力されるが、転移学習にはそれは不要でその前段階の特徴量だけ抽出できれば良いので include_top=False を指定。 input_shape=(96, 96, 3) だと(None, 3, 3, 1280) の特徴量が出力されるようになる。 これをさらに pooling='avg' を指定することで平均値を取るpoolingにより (None, 1280) の2D Tensorが出力されるようになる(pooling='max'と指定することもできる)。 この1280個の値を特徴量ベクトルとして利用して結合層の部分だけ学習させて最適化していく。

feature extraction

base networkとなるMobileNetV2を固定させたまま使う場合は 入力画像に対する特徴量の出力は常に固定になるはずなので、先に全画像に対する特徴量を抽出しておくことが出来る。

Eager executionのおかげで単純に out = model(images).numpy() といった形で呼び出して出力の値を取得できる。

directoryを走査して画像データを読み込んでモデルへの入力値を作り、出力された特徴量ベクトルと対応するlabel indexをセットにして保存。どうせ後でshuffleして使うので順番は気にしない。 ここではnumpy arrayにしてnpzで保存する。

def dump_features(data_dir, features_dir):
    with open(os.path.join(data_dir, 'labels.txt'), 'r') as fp:
        labels = [line.strip() for line in fp.readlines()]

    model = tf.keras.applications.MobileNetV2(
        input_shape=INPUT_IMAGE_SIZE + (3,),
        include_top=False,
        pooling='avg',
        weights='imagenet')
    model.trainable = False

    features, label = [], []
    for root, dirs, files in os.walk(data_dir):
        for filename in files:
            image = tf.io.read_file(os.path.join(root, filename))
            image = tf.io.decode_image(image, channels=3)
            image = tf.image.convert_image_dtype(image, dtype=tf.float32)
            features.append(model(tf.expand_dims(image, axis=0)).numpy().flatten())
            label.append(labels.index(os.path.basename(root)))
    np.savez(os.path.join(features_dir, 'out.npz'), inputs=features, targets=label)

流石にCPUだとそこそこ時間がかかるが、数千件程度なら数分待てば完了する。

>>> import numpy as np
>>> npz = np.load('features/training.npz')
>>> npz['inputs']
array([[0.19352797, 0.        , 0.        , ..., 0.        , 1.0640727 ,
        2.7432559 ],
       [0.        , 0.        , 0.        , ..., 0.        , 1.4123839 ,
        3.057496  ],
       [0.18237096, 0.        , 0.04695423, ..., 0.33184642, 0.8117143 ,
        1.5288072 ],
       ...,
       [0.        , 0.        , 0.06801239, ..., 0.        , 0.13248181,
        1.6491733 ],
       [0.2550986 , 0.        , 0.10878149, ..., 0.        , 0.0785673 ,
        0.0311639 ],
       [0.        , 0.        , 0.        , ..., 0.        , 0.        ,
        2.0744066 ]], dtype=float32)
>>> npz['inputs'].shape
(6277, 1280)
>>> npz['targets']
array([24, 24, 24, ..., 14, 14, 14])
>>> npz['targets'].shape
(6277,)

あとはこの入出力に最適化するように結合層を学習させていけば良い。

tf.data.Dataset による学習データ準備

def dataset(category):
    npz = np.load(os.path.join(features_dir, f'{category}.npz'))
    inputs = npz['inputs']
    targets = npz['targets']
    size = inputs.shape[0]
    return tf.data.Dataset.from_tensor_slices((inputs, targets)).shuffle(size), size

training_data, training_size = dataset('training')
validation_data, validation_size = dataset('validation')

training用と validation用でそれぞれ tf.data.Dataset.from_tensor_slicesinputstargets のtupleを渡すことで、Modelに与える訓練用入力データの準備ができる。

>>> for images, labels in training_data.batch(32).take(1):
...     print(images.shape, labels.shape)

(32, 1280) (32,)

tf.keras.Sequential によるModel定義

学習させるModelは tf.keras.Sequentialtf.keras.layers.Layer のlistを渡して記述していく。

with open(os.path.join(args.data_dir, 'labels.txt')) as fp:
    labels = [line.strip() for line in fp.readlines()]
classes = len(labels)

model = tf.keras.Sequential([
    tf.keras.layers.InputLayer((1280,)),
    tf.keras.layers.Dropout(rate=0.2),
    tf.keras.layers.Dense(
        classes,
        activation='softmax',
        kernel_regularizer=tf.keras.regularizers.l2(1e-4)),
])
model.summary()

入力となる1280の特徴量ベクトルに対しDropoutを入れつつDense layerで 29 classesの分類になるようにしているだけ。最終層のactivationはsoftmaxに。

Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
dropout (Dropout)            (None, 1280)              0
_________________________________________________________________
dense (Dense)                (None, 29)                37149
=================================================================
Total params: 37,149
Trainable params: 37,149
Non-trainable params: 0
_________________________________________________________________

summaryでモデルの概要が簡単に分かって便利。

学習

まずはlossやmetricsの定義など。 Model.compile() で何を計測して何を減少させていくか、などを決定する。

model.compile(
    optimizer=tf.keras.optimizers.RMSprop(),
    loss=tf.keras.losses.SparseCategoricalCrossentropy(),
    metrics=[tf.keras.metrics.SparseCategoricalAccuracy()])

この転移学習用に用意したdatasetではtargetsのlabelはindexを表す単一のint値なので、one-hot vectorに変換していない場合は tf.keras.losses.SparseCategoricalCrossentropy のように Sparseとついたものを使う。 one-hotな表現の値を使いたい場合はtf.keras.utils.to_categorical を利用して変換することも出来るようだ。 optimizerは RMSpropがデフォルトで使われるらしい。

ここまで準備できたら学習開始。 Model.fit() を呼ぶだけ。

history = model.fit(
    training_data.repeat().batch(batch_size),
    steps_per_epoch=training_size // batch_size,
    epochs=100,
    validation_data=validation_data.batch(batch_size),
    validation_steps=validation_size // batch_size,
    callbacks=[tf.keras.callbacks.TensorBoard()])
print(history.history)

与える学習データには上で作った training_data を使う。これは繰り返し使うので .repeat()を指定。 .batch() で処理するのでそれぞれ data_sizeをbatch_sizeで割ったsteps数を指定している。

callbacks で各epochが終わったときなどに特別な処理を挟むことが出来て、ここでは tf.keras.callbacks.TensorBoard() を渡すことで ./logs以下にTensorBoardで確認する用のlogデータを書き込んでくれるようになる。

Train for 98 steps, validate for 12 steps
Epoch 1/100
2019-09-16 00:13:08.160855: I tensorflow/core/profiler/lib/profiler_session.cc:184] Profiler session started.
98/98 [==============================] - 1s 14ms/step - loss: 2.2567 - sparse_categorical_accuracy: 0.3463 - val_loss: 1.4933 - val_sparse_categorical_accuracy: 0.5755
Epoch 2/100
98/98 [==============================] - 0s 3ms/step - loss: 1.2355 - sparse_categorical_accuracy: 0.6362 - val_loss: 1.0762 - val_sparse_categorical_accuracy: 0.6966
Epoch 3/100
98/98 [==============================] - 0s 3ms/step - loss: 0.8975 - sparse_categorical_accuracy: 0.7380 - val_loss: 0.8647 - val_sparse_categorical_accuracy: 0.7630
Epoch 4/100
98/98 [==============================] - 0s 3ms/step - loss: 0.7226 - sparse_categorical_accuracy: 0.7943 - val_loss: 0.7586 - val_sparse_categorical_accuracy: 0.7943
Epoch 5/100
98/98 [==============================] - 0s 3ms/step - loss: 0.6036 - sparse_categorical_accuracy: 0.8364 - val_loss: 0.6855 - val_sparse_categorical_accuracy: 0.8073
...

Dense Layerの部分だけの学習なのでとても速く、CPUでも1epochあたり0.3秒程度で あっという間に学習が進む。lossが減少し sparse_categorical_accuracyが増加していくのが見てとれる。

とりあえず 100 epoch回して TensorBoardで確認すると

loss: f:id:sugyan:20190916231225p:plain

sparse_categorical_accuracy: f:id:sugyan:20190916231243p:plain

橙がtraining, 青がvalidation。training_dataに対してはどんどん正答率が上がるが validation_dataに対しての結果は90%に届くか届かないか…程度のところで止まってしまうようだ。 まぁImageNetで学習したMobileNetV2が将棋駒画像に対してどれだけの特徴を捉えられているかというのを考えるとそんなものかな、という気はする。

学習後のModelを保存

学習したDense Layerを使って、base networkのMobileNetV2と繋げてまた新しく tf.keras.Sequential を作る。こうすることで、今度は (96, 96, 3)の入力に対して (29)の出力をする画像分類器として動くModelになる。

classifier = tf.keras.Sequential([
    tf.keras.applications.MobileNetV2(
        input_shape=INPUT_IMAGE_SIZE + (3,),
        include_top=False,
        pooling='avg',
        weights='imagenet'),
    model,
])
classifier.trainable = False
classifier.summary()
Model: "sequential_1"
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
mobilenetv2_1.00_96 (Model)  (None, 1280)              2257984
_________________________________________________________________
sequential (Sequential)      (None, 29)                37149
=================================================================
Total params: 2,295,133
Trainable params: 0
Non-trainable params: 2,295,133
_________________________________________________________________

簡単に繋ぎ直して使うことが出来て便利…。

あとはこのModelを丸ごと保存。.save() を呼ぶだけで良い。

classifier.save('transfer_classifier.h5')

test_dataでの評価

保存したModelを使って、学習には使っていない testのデータを使って精度を評価してみる。

feature extractionしたときと同様にディレクトリを走査し 画像ファイルとlabel indexをzipして tf.data.Datasetを生成する。

保存したModelは tf.keras.models.load_model() で読み込める。学習時と同様に loss に SparseCategoricalCrossentropy, metricsに SparseCategoricalAccuracy を指定してcompileして、評価で見るべき値を定める。

def evaluate(data_dir, model_path):
    with open(os.path.join(data_dir, 'labels.txt'), 'r') as fp:
        labels = [line.strip() for line in fp.readlines()]
    label_to_index = {label: index for index, label in enumerate(labels)}

    def load_image(image_path):
        image = tf.io.decode_jpeg(tf.io.read_file(image_path), channels=3)
        return tf.image.convert_image_dtype(image, tf.float32)

    image_paths = pathlib.Path(os.path.join(data_dir, 'test')).glob('*/*.jpg')
    image_paths = list(image_paths)
    label_index = [label_to_index[path.parent.name] for path in image_paths]
    images_ds = tf.data.Dataset.from_tensor_slices([str(path) for path in image_paths]).map(load_image)
    labels_ds = tf.data.Dataset.from_tensor_slices(label_index)
    test_data = tf.data.Dataset.zip((images_ds, labels_ds)).shuffle(len(image_paths))

    model = tf.keras.models.load_model(model_path)
    model.trainable = False
    model.compile(
        loss=tf.keras.losses.SparseCategoricalCrossentropy(),
        metrics=[tf.keras.metrics.SparseCategoricalAccuracy()])
    model.summary()

    test_result = model.evaluate(test_data.batch(1))
    print(test_result)
745/745 [==============================] - 13s 17ms/step - loss: 0.4405 - sparse_categorical_accuracy: 0.9114
[0.44054528336796983, 0.9114094]

今回のtransfer learningでのModelの、全745件のtest_data画像への正答率は 91.14% となることが分かった。

fine tuning

transfer learningでは90%前後の精度が限界のようだが、base networkのMobileNetV2部分も学習対象に含めて訓練していくとどうなるか。

Model定義

model = tf.keras.Sequential([
    tf.keras.applications.MobileNetV2(
        input_shape=INPUT_IMAGE_SIZE + (3,),
        include_top=False,
        pooling='avg',
        weights='imagenet'),
    tf.keras.layers.Dropout(rate=0.1),
    tf.keras.layers.Dense(
        len(labels),
        activation='softmax',
        kernel_regularizer=tf.keras.regularizers.l2(1e-4)),
])
model.summary()
Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
mobilenetv2_1.00_96 (Model)  (None, 1280)              2257984
_________________________________________________________________
dropout (Dropout)            (None, 1280)              0
_________________________________________________________________
dense (Dense)                (None, 29)                37149
=================================================================
Total params: 2,295,133
Trainable params: 2,261,021
Non-trainable params: 34,112
_________________________________________________________________

MobileNetV2Denseで繋げただけというModelの構造自体はで変わらない。 ただ今度はMobileNetV2の部分も学習対象とするので Trainable params37,149 がら 2,261,021 に激増している。

tf.keras.preprocessing.image.ImageDataGeneratorを使ってaugmentation

tf.keras.preprocessing というmoduleがあって、ここにはデータの前処理のためのutitityがある。

画像に対しても、以前は tf.image の様々なoperationを自分で組み合わせて作っていたdata augmentationの処理をまとめてやってくれるclassがある。

training_datagen = tf.keras.preprocessing.image.ImageDataGenerator(
    rotation_range=2,
    width_shift_range=2,
    height_shift_range=2,
    brightness_range=(0.8, 1.2),
    channel_shift_range=0.2,
    zoom_range=0.02,
    rescale=1./255)

このような形で、augmentationをかける際のパラメータ、レンジを指定して ImageDataGenerator を作る。 回転、縦横への移動、拡大縮小や明るさ変更など、色々指定できる。

このGeneratorに対して データを流し込んでいくことで iteratorが作られる。 既に展開してあるデータがあれば flow で、directoryだけ指定してそこから画像ファイルを読み取ってもらう場合は flow_from_directory で。

training_data = training_datagen.flow_from_directory(
    os.path.join(data_dir, 'training'),
    target_size=(96, 96),
    classes=labels,
    batch_size=batch_size)

こうすると、training_dataから読み出すたびに ImageDataGenerator生成時に指定したパラメータに従って変換をかけた画像たちがbatchで生成されて出力されるようになる。

極端にパラメータを大きくしてみると 以下のような感じで、傾いてたり白飛びしてたり様々。 このへんはどういう画像分類タスクを対象とするかによって適切なパラメータが異なるものになりそう。

f:id:sugyan:20190916231310p:plain

ともあれ、この ImageDataGenerator からの出力を使って学習させていく。

model.compile(
    optimizer=tf.keras.optimizers.RMSprop(),
    loss=tf.keras.losses.CategoricalCrossentropy(),
    metrics=[tf.keras.metrics.CategoricalAccuracy()])

ImageDataGenerator.flow_from_directoryclass_mode='categorical' がdefaultになっていて、これはtargetsの出力がone-hotな状態になる。ので、これを学習に使う場合は Sparse ではない CategoricalCrossentropy, CategoricalAccuracy を使うことになる。

そして学習。 training_dataImageDataGenerator のものなので、Model.fit ではなく Model.fit_generator を使う。

history = model.fit_generator(
    training_data,
    epochs=100,
    validation_data=validation_data,
    callbacks=[
        tf.keras.callbacks.TensorBoard(),
        tf.keras.callbacks.ModelCheckpoint(
            os.path.join(weights_dir, 'finetuning_weights-{epoch:02d}.h5'),
            save_weights_only=True),
    ])

callbackstf.keras.callbacks.ModelCheckpoint() を追加してみた。epochごとにmodelの状態を保存してくれる。save_weights_only にすればmodel定義は無視して変数の値だけを保存してくれるようになる。

Epoch 1/100
99/99 [==============================] - 53s 539ms/step - loss: 0.9509 - categorical_accuracy: 0.7492 - val_loss: 4.0943 - val_categorical_accuracy: 0.3885
Epoch 2/100
99/99 [==============================] - 52s 520ms/step - loss: 0.2898 - categorical_accuracy: 0.9246 - val_loss: 8.5777 - val_categorical_accuracy: 0.1973
Epoch 3/100
99/99 [==============================] - 51s 515ms/step - loss: 0.2019 - categorical_accuracy: 0.9498 - val_loss: 7.5333 - val_categorical_accuracy: 0.2512
Epoch 4/100
99/99 [==============================] - 52s 521ms/step - loss: 0.1509 - categorical_accuracy: 0.9651 - val_loss: 11.6409 - val_categorical_accuracy: 0.0907
...

これは流石にCPUではかなりの時間がかかってしまう。手元のMacBookProだと 200s/epoch くらい。 Google Colaboratory の GPU Runtime を使うと 52s/epoch くらいに短縮できるようだ。 ちなみに TPU Runtime では TensorFlow 2.0をまだ使えなくて、そもそも2.0rc時点ではまだTPUをsupportできていないらしい

ともあれ、どうにか 100 epoch回してみると

loss: f:id:sugyan:20190916231344p:plain

categorical_accuracy: f:id:sugyan:20190916231402p:plain

training(橙)は初期から順調にlossが減少してaccuracyも上昇するが、validation(青)はどうにも最初の数epochのうちは全然安定しない。 が、辛抱強く 30〜40epochくらいまで回していると突如としてlossの減少が始まり どんどん精度が上がってくる。 これはちょっとよく分からないけど fine-tuningでbase networkも学習するっていうのはこういうことなのかなぁ…。

ともかく、最終的にはかなり良い精度になっていそう。

評価

transfer learningのときと同じように model.save() で学習後のモデルを保存し、同じ評価scriptを使って evaluate を実行。

745/745 [==============================] - 14s 19ms/step - loss: 0.0542 - sparse_categorical_accuracy: 0.9906
[0.054189728634688225, 0.99060404]

転移学習より圧倒的に高い、 99.06% の精度が出た。すごい。 むしろ何を間違えているのかというと…

745件中 7件

f:id:sugyan:20190916231550j:plain (正解:△成香, 推論:△と金)

f:id:sugyan:20190916231607j:plain (正解:△香車, 推論:△金将)

f:id:sugyan:20190916231618j:plain (正解:△香車, 推論:△歩兵)

f:id:sugyan:20190916231630j:plain (正解:▲成香, 推論:▲成銀)

f:id:sugyan:20190916231641j:plain (正解:▲成香, 推論:▲と金)

f:id:sugyan:20190916231653j:plain (正解:▲成香, 推論:▲成銀)

f:id:sugyan:20190916231713j:plain (正解:▲歩兵, 推論: △成銀)

香車、成香はバリエーションあるわりにはデータあまり集めることができていなくて確かに間違うかもな、という感じ。とはいえ向き(先手か後手か)はだいたい見分けることが出来ている… と思ったら最後のやつは歩兵を後手の成銀と全然見当違いな結果になっていて謎。まぁ成銀もあまりデータ多くないので変なところで特徴を見てしまうのかも。

と考察できるくらいにはいいかんじに分類器として動いているようだ。

ここからは このModelを使ってJavaScriptやMobileAppで推論を動かしていく、というのをやっていくつもり

Repository

ISUCON9 予選敗退した

ISUCON9。今年は縁あって声かけていただき id:Soudai さんと id:kamipo さんと、「失敗から学ぶISUCONの正しい歩き方」というチームで出場した。

練習会

インターネット上でよく知っている人たちとはいえ 実際に一緒に仕事をしたことも無ければチームを組んで一緒に何かをしたことがあるわけでもない。 予選の2週間前に一度集まって、1日かけて前年の予選問題を再現しつつ試し解きしてみる、という会をした。 言語は3人で共通した得意なものがあるわけではなかったので とりあえずRubyで、ということにした。

そのときの感覚では「やっぱり色んなところで躓くこともあるかもしれないけど 正しく計測して早めに大きな方針を決めて改善をしていけばそれなりの成績には辿り着けるだろう」という感じ。 DB関連はとにかく2人が強いので、自分はアプリケーションコードを如何に正確に早く書いていけるかが勝負になる、と。

当日

練習会のときはそれぞれのMBPで作業したけど コード書くのは共有画面あった方が良さそう、と思い 当日はモニタを1台かりて映しながら作業するようにした。あと自作の Claw44 も持参。 作業環境は万全だった

11:19 初期セットアップ → 2,110

インフラまわりはSoudaiさんに完全丸投げしていたので サーバが立ち上がるまで僕とkamipoさんはマニュアルを読み込む。1台だけ立ち上がったら即git pushしてローカルで動かせるようにしたりしつつ コードを読んで動作を把握する作業。 想像してた以上にすごい作り込まれている クオリティの高いWebサービスになっていて絶句した。

トラブルなどもあったけど 11:19 ようやく初期状態での1回目のベンチマークが回った。スコアは 2,110

11:25 Rubyに実装切り替え → 2,310

まずは初期実装のGoからRubyへの変更。今回はスコアのブレは少なそうだ。 そこからaccess logを切り替えて alp で傾向を分析するといった作業をSoudaiさんに任せつつ、kamipoさんとアプリケーションの動作を追い続ける。

あと明らかにCPUの負荷は高いし3台で動作させて分散させるのはやっていこう、ということでそのへんの作業もSoudaiさんに一任。

13:07 campaign を上げてみる → 4,900

どうやら campaign という値をいじると負荷の挙動も変わってきそうだ、ということで試しに上げて動かしてみる。 タイムアウトなども発生するが どうにかボトルネックを解消して捌けるようにしつつこの数値を上げていければ良さそう、というのは分かってきた

14:03 3台構成稼動 → 2,510

3台でappを起動して 1台は nginx + mysql をメインにして集約、といった構成がやっと動いた。

14:07 index追加、campaign を上げる → 6,410

明らかにindexがなくて遅くなっている items.created_at のあたりにindexを貼って 多少はやくなるはず、ってことで campaign2 に上げてみた。 ようやく改善の効果が出てきて大事なボトルネックも見えてくる。ということで昼飯食べつつ今後の作戦会議。

kamipoさんはloginまわりの負荷軽減、Soudaiさんはstaticファイル配信まわりやdeploy環境まわりの整備など、で僕は不変な categories 情報をDBから引かずにapp内で持つように変更する、という方針で動き始める

16:04 get_category_by_id の改善 → 6,730

上記の通り categories 情報をapp内の変数から出すよう変更したものが動かせた。が 思ったほどスコアは改善しなかった…

16:57 login まわり改善 → 8,970

kamipoさんの改善が効いた。

9,650

その後 細々した修正でスコアを上げるも 10,000 までは届かず。そのまま最終スコア 9,650 でフィニッシュ。

最終的な予選通過ラインが 10,000 前後だったようだが そこまでは届かず予選敗退に終わった…

反省点

僕は早く正確にコードを変更していくのが役割だったはずなのに、結局そこで価値を出せなかった。 configure 内で categories情報を settings に入れて get_category_by_id は素早く改善できたものの、その後 /settings のresponseのところも変更しようとして

-      categories = db.xquery('SELECT * FROM `categories`').to_a
+      categories = settings.categories.map do |_, category|
+        category.delete('parent_category_name')
+        category
+      end

という変更を入れてFailするようになってしまい、revertして 動作確認して また失敗して、と原因の切り分けに時間がかかってしまい 40分くらいロスしてしまった… (なんてことはない、parent_category_name情報をresponseから省こうとしてdeleteした結果破壊的な変更になってsettings全体の値が変わってしまうという初歩的なバグ。。)

あとは外部リクエストを複数投げているところはどうにか並行化したいところだったけど GoやNodeならわりと簡単にできそうでもRubyでやるのは知見が無くて簡単には手が出しづらく、踏み込めなかった。 限られた時間内では「確実に出来そうな変更」にとどめざるを得なく、そこ以外のはやく出来そうなところから優先して手をつけることになり、最終的に残り1時間くらいでもう出来ることがなくなってベンチマークガチャを回すくらいしか出来なくなってしまった。 これは単純に能力不足と言うほかない。

3台構成にしようとしててMySQLが疎通しなくてDBのスペシャリスト2人いても苦戦していたところに bind-address 127.0.0.1が原因だって見抜いたことが僕の一番の功績だったかもしれない。

チーム的には…

最後の1時間くらいで出来ることがほぼ無くなって「次の一手」を打てなかったのが厳しかったか… 最初はしっかりaccess logを解析してボトルネックを見つけて、とやっていたけど 最後は「うーん やっぱり /buy が遅いみたいだけど どうすりゃええんや…」みたいな状態で そこから詳細に原因を調べて改善に繋げていくことが出来なかった。外部リクエストの測定も出来なかったし あと数時間あったとしても有効な改善を思い付いて実行できたかどうか分からない。

競技終了後に「これ、『明日もう1回 同じ問題に8時間取り組んでいいですよ』って言われても勝てる気しないっすね…」ってなってしまっていて完敗な感じになってしまっていた。

あとは全体的に作業スピードが遅くて時間に余裕が無くなりすぎていたか…?という気もする。 1人チームの学生さんに負けないくらいに各自がミスなく素早く動いて 最初の数時間をもっと短縮できていたら良かったのかもしれない。おじさんたちももっと精進して頑張らねば。

感想

結果は悔しいものに終わってしまったけれど、練習会・予選当日ともに このメンバーで集中して議論して改善に取り組んでいく過程はとてもエキサイティングで楽しかったです。誘っていただきありがとうございました!!! また是非このメンバーで一緒に戦いたいな

運営の皆様には今回も良質な問題を提供していただき、ありがとうございました。 本当に 予選であんな盛り沢山の参照実装を用意してくるとは驚きでした。。

あとスタンプめっちゃ気に入ってます

本戦当日に妻と花火大会に行く予定でチケット買ってしまっててダブルブッキングになる不安があったんですが、心おきなく一緒に花火を楽しんでこようと思います。。。

電子工作 5作目・Claw44

f:id:sugyan:20190717112703j:plain

HelixPico、Mint60、ErgoDash、Corne Cherry に続いて、自作キーボード 5作目。

今回作ったのは Claw44。 この記事はClaw44を使って書いています。

booth.pm

なぜClaw44を選んだか

Corne Cherryで概ね自分の理想は実現されていたけど、前回の記事に書いた通り

親指用のキーは下部に3つ 押しやすい位置に斜めに並んでいるのだけど、 どうもそれでも最内側のキーがまだ少し遠くて押しづらい…。

というのが唯一の懸念だった。

そこで登場したClaw44。設計者の id:yfuku さんが、まさにそこにこだわって作られている。

blog.yfuku.com

ので自分のニーズにとても一致している。 Corne Cherryと同様にコンパクトで最小限のキー数なColumn-Staggered配列というのも理想的。

組み立て

前作Corne CherryのときはチップダイオードやLEDの表面実装が難しすぎて泣きそうになっていたけど、Claw44はそういう高難度の要素が殆どなかったので、 ビルドガイド を読みながら組み立てるだけで だいぶラクに完成させることができた。

キースイッチ・キーキャップ

今回のキースイッチは "Blue Zilent" 65g で。

yushakobo.jp

キーキャップは前作と同じ XDA Blank を使った。

talpkeyboard.stores.jp

親指の部分で 1.25U のものがどうすべきか分からないけど 今はお試しで送っていただいた傾斜つきのものを使わせていただいている。

キーマップ

前作同様、自分にとって最適のものを設定。

--- keyboards/claw44/keymaps/default/keymap.c   2019-07-08 13:16:45.000000000 +0900
+++ keyboards/claw44/keymaps/sugyan/keymap.c    2019-07-08 15:26:53.000000000 +0900
@@ -38,15 +38,15 @@
 const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = {

   [_QWERTY] = LAYOUT( \
-  //,--------+--------+---------+--------+---------+--------.   ,--------+---------+--------+---------+--------+--------.
-     KC_ESC , KC_Q   , KC_W    , KC_E   , KC_R    , KC_T   ,     KC_Y   , KC_U    , KC_I   , KC_O    , KC_P   , KC_MINS,
-  //|--------+--------+---------+--------+---------+--------|   |--------+---------+--------+---------+--------+--------|
-     KC_TAB , KC_A   , KC_S    , KC_D   , KC_F    , KC_G   ,     KC_H   , KC_J    , KC_K   , KC_L    , KC_SCLN, KC_QUOT,
-  //|--------+--------+---------+--------+---------+--------|   |--------+---------+--------+---------+--------+--------|
-     KC_LSFT, KC_Z   , KC_X    , KC_C   , KC_V    , KC_B   ,     KC_N   , KC_M    , KC_COMM, KC_DOT  , KC_SLSH, KC_RSFT,
-  //`--------+--------+---------+--------+---------+--------/   \--------+---------+--------+---------+--------+--------'
-                       KC_A_DEL, KC_G_EN, KC_L_SPC, KC_C_BS,     KC_C_BS, KC_R_ENT, KC_G_JA, KC_A_DEL
-  //                 `----------+--------+---------+--------'   `--------+---------+--------+---------'
+  //,--------+--------+--------+--------+--------+--------.   ,--------+---------+--------+---------+--------+--------.
+     KC_TAB , KC_Q   , KC_W   , KC_E   , KC_R   , KC_T   ,     KC_Y   , KC_U    , KC_I   , KC_O    , KC_P   , KC_BSLS,
+  //|--------+--------+--------+--------+--------+--------|   |--------+---------+--------+---------+--------+--------|
+     KC_LCTL, KC_A   , KC_S   , KC_D   , KC_F   , KC_G   ,     KC_H   , KC_J    , KC_K   , KC_L    , KC_SCLN, KC_QUOT,
+  //|--------+--------+--------+--------+--------+--------|   |--------+---------+--------+---------+--------+--------|
+     KC_LSFT, KC_Z   , KC_X   , KC_C   , KC_V   , KC_B   ,     KC_N   , KC_M    , KC_COMM, KC_DOT  , KC_SLSH, KC_R_ENT,
+  //`--------+--------+--------+--------+--------+--------/   \--------+---------+--------+---------+--------+--------'
+                       KC_LALT, KC_LGUI, KC_L_SPC,KC_C_BS,     KC_C_BS, KC_RSFT , RAISE  , LOWER
+  //                 `---------+--------+--------+--------'   `--------+---------+--------+---------'
   ),

   //   \ ^ ! & |  @ = + * % -
@@ -55,23 +55,23 @@

   [_RAISE] = LAYOUT( \
   //,--------+--------+--------+--------+--------+--------.   ,--------+--------+--------+--------+--------+--------.
-     _______, KC_BSLS, KC_CIRC, KC_EXLM, KC_AMPR, KC_PIPE,     KC_AT  , KC_EQL , KC_PLUS, KC_ASTR, KC_PERC, KC_MINS,
+     KC_ESC , KC_EXLM, KC_AT  , KC_HASH, KC_DLR , KC_PERC,     KC_CIRC, KC_AMPR, KC_ASTR, KC_LPRN, KC_RPRN, KC_DEL ,
   //|--------+--------+--------+--------+--------+--------|   |--------+--------+--------+--------+--------+--------|
-     KC_LPRN, KC_HASH, KC_DLR , KC_DQT , KC_QUOT, KC_TILD,     KC_LEFT, KC_DOWN,  KC_UP , KC_RGHT, KC_GRV , KC_RPRN,
+     KC_LCTL, KC_UNDS, KC_PLUS, KC_LCBR, KC_RCBR, KC_TILD,     KC_GRV , KC_MINS, KC_EQL , KC_LBRC, KC_RBRC, _______,
   //|--------+--------+--------+--------+--------+--------|   |--------+--------+--------+--------+--------+--------|
-     _______, _______, _______, _______, KC_LCBR, KC_LBRC,     KC_RBRC, KC_RCBR, _______, _______, _______, _______,
+     KC_LSFT, KC_1   , KC_2   , KC_3   , KC_4   , KC_5   ,     KC_6   , KC_7   , KC_8   , KC_9   , KC_0   , _______,
   //`--------+--------+--------+--------+--------+--------/   \--------+--------+--------+--------+--------+--------'
-                       _______, _______, _______, _______,     _______, _______, _______, RESET
+                       KC_LALT, KC_LGUI, KC_L_SPC,KC_C_BS,     KC_C_BS, KC_RSFT, RAISE  , LOWER
   //                  `--------+--------+--------+--------'   `--------+--------+--------+--------'
   ),

   [_LOWER] = LAYOUT( \
   //,--------+--------+--------+--------+--------+--------.   ,--------+--------+--------+--------+--------+--------.
-     KC_F1  , KC_F2  , KC_F3  , KC_F4  , KC_F5  , KC_F6  ,     _______, KC_EQL , KC_PLUS, KC_ASTR, KC_PERC, KC_MINS,
+     _______, KC_F1  , KC_F2  , KC_F3  , KC_F4  , KC_F5  ,     KC_F6  , KC_F7  , KC_F8  , KC_F9  , KC_F10 , _______,
   //|--------+--------+--------+--------+--------+--------|   |--------+--------+--------+--------+--------+--------|
-     _______, KC_1   , KC_2   , KC_3   , KC_4   , KC_5   ,     KC_6   , KC_7   , KC_8   , KC_9   , KC_0   , _______,
+     _______, _______, _______, _______, _______, _______,     KC_LEFT, KC_DOWN, KC_UP  , KC_RGHT, _______, _______,
   //|--------+--------+--------+--------+--------+--------|   |--------+--------+--------+--------+--------+--------|
-     KC_F7  , KC_F8  , KC_F9  , KC_F10 , KC_F11 , KC_F12 ,     _______, _______, KC_COMM, KC_DOT , KC_SLSH, _______,
+     _______, _______, _______, _______, _______, _______,     KC_HOME, KC_PGDN, KC_PGUP, KC_END , _______, _______,
   //`--------+--------+--------+--------+--------+--------/   \--------+--------+--------+--------+--------+--------'
                        RESET  , _______, _______, _______,     _______, _______, _______, _______
   //                  `--------+--------+--------+--------'   `--------+--------+--------+--------'

結局 最内側の親指は相当手を広げないと届かないので使わない感じになっている…

使用感

最初は少し戸惑ったけど、慣れるとまったく気にならないレベル。 仕事用に会社でずっと使い続けていて、ほぼ不満無く快適に毎日使っている。

ただ普通のXDAキーキャップだと角が指に当たって少し痛くなるので やはり傾斜つきのものが欲しいな、というのが最近の気持ち。1.25Uの外隣のキー用に2個だけ何か欲しい…