「くずし字」識別チャレンジ

β版ProbSpaceコンペ第2弾!

賞金: 100,000 参加ユーザー数: 181 5年弱前に終了

学習データと試験データの「字母」のかたより

学習データと試験データの「字母」のかたより

1. はじめに

KMNISTをCNNモデルで評価したところ、CV=0.99程度に対してLB=0.971と出ました。

したがって、KMNISTの学習データと試験データにかたよりがあるのではないかと考えてデータを調べました。

その結果、ちょっと面白い評価ができましたので紹介します。

この検討には、ProbSpaceから提供されたkmnist-test-imgs.npz、kmnist-train-labels.npz、kmnist-train-imgs.npz以外に、人文学オープンデータ共同利用センターで公開されているkmnist-test-labels.npz(試験データの正解ラベル)も用いました。 コンペ期間中に外部データを参照することはルール違反ですが、コンペも数か月前に終わっていますので、問題ないと判断します。(というか、話題提供のために許してください…)

また、この検討はQiitaのTSNE Grid で MNIST を調べてみたを参考にしました。

2. くずし字とKMNISTについて

くずし字(変体仮名ともいう)には、現在の平仮名のもとになったものの他に様々な字母(もとになった漢字)が存在します。(「き」→「畿」,「起」,「支」、「す」→「寸」,「春」,「須」など) (変体仮名 五十音順一覧 )

同じ平仮名のくずし字に複数の字体があるのはこのためです。

KMNISTには、「お」,「き」,「す」,「つ」,「な」,「は」,「ま」,「や」,「れ」,「を」の十文字のくずし字が、学習データで60,000字(それぞれの字で6,000字)、試験データで10,000字(それぞれの字で1,000字)収められています。

3. 準備

まずは利用しているモジュールと画像データおよびラベルデータを読みます。

%matplotlib inline
%config InlineBackend.figure_format = 'retina'

import numpy as np
import seaborn as sns
import matplotlib.pyplot as plt
import matplotlib.lines as mlines
import matplotlib.patches as mpatches
from matplotlib import offsetbox, patheffects
from mpl_toolkits.axes_grid1 import make_axes_locatable
from scipy.spatial.distance import cdist
from sklearn.datasets import fetch_openml
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
from sklearn.utils import shuffle
from sklearn.preprocessing import minmax_scale
from lapjv import lapjv
from PIL import Image

rc = {
  'font.family': ['sans-serif'],
  'font.sans-serif': ['Open Sans', 'Arial Unicode MS'],
  'font.size': 12,
  'figure.figsize': (8, 6),
  'grid.linewidth': 0.5,
  'legend.fontsize': 10,
  'legend.frameon': True,
  'legend.framealpha': 0.6,
  'legend.handletextpad': 0.2,
  'lines.linewidth': 1,
  'axes.facecolor': '#fafafa',
  'axes.labelsize': 10,
  'axes.titlesize': 14,
  'axes.linewidth': 0.5,
  'xtick.labelsize': 10,
  'xtick.minor.visible': True,
  'ytick.labelsize': 10,
  'figure.titlesize': 14
}
sns.set('notebook', 'whitegrid', rc=rc)

def colorize(d, color, alpha=1.0):
  rgb = np.dstack((d,d,d)) * color
  return np.dstack((rgb, d * alpha)).astype(np.uint8)

#colors = sns.color_palette('tab10')
colors = sns.color_palette(n_colors=24)
# Load the data(kmnist-test-labels.npzは外部データ)
Train_imgs = np.load("./data/kmnist-train-imgs.npz")['arr_0']
target = np.load("./data/kmnist-train-labels.npz")['arr_0']
Test_imgs = np.load("./data/kmnist-test-imgs.npz")['arr_0']
Test_target = np.load("./data/kmnist-test-labels.npz")['arr_0']
# Train_imgsとTest_imgsを一次元データにする
data = Train_imgs.reshape(-1,784)
Test_data = Test_imgs.reshape(-1,784)

試験データのラベルを+10して、学習データのラベルと区別できるようにしたうえで、試験データと学習データを結合します。

# テストデータの正解ラベルを+10する。
Test_target += 10

data = np.concatenate([data, Test_data])
target = np.concatenate([target, Test_target])

ラベルと仮名の対応を設定します。

学習データと試験データが区別できるようにします。

# 文字対応リストと日本語対応フォントの設定
labels = { 0: 'お(train)', 1: 'き(train)', 2: 'す(train)', 3: 'つ(train)', 4: 'な(train)', 
          5: 'は(train)', 6: 'ま(train)', 7: 'や(train)', 8 : 'れ(train)', 9 : 'を(train)',
           10: 'お(test)', 11: 'き(test)', 12: 'す(test)', 13: 'つ(test)', 14: 'な(test)', 
          15: 'は(test)', 16: 'ま(test)', 17: 'や(test)', 18 : 'れ(test)', 19 : 'を(test)'}
# plt.rcParams['font.family'] = 'IPAPGothic'
plt.rcParams['font.family'] = 'Malgun Gothic'

4. くずし字の画像データ

くずし字の画像を30個ずつ表示します。

上の10が学習データのもので、下の10が試験データのものです。

よく見ると、ほとんどの文字で、異なる字母を持つものが混在しています。

#data, target = fetch_openml('mnist_784', version=1, return_X_y=True)
#data = data.astype('float32')
#target = target.astype('uint8')

fig, ax = plt.subplots(figsize=(16,8))

size = 28
dim = (20,30)

img = np.zeros((size * dim[0], size * dim[1], 4),dtype='uint8')

for num in range(20):
    for d, t, i in zip(data[target == num], target[target == num], range(dim[1])):
      ix = num
      iy = i
      img[ix*size:(ix+1)*size,iy*size:(iy+1)*size,:] = colorize(d.reshape(size,size), colors[t], 0.9)

ax.imshow(img)
ax.set_axis_off()
plt.show()

5. t-SNEで2次元マッピング

次は、学習データのうち24×24=576のデータをランダムに抽出し、t-SNE を利用して、次元削減し、平面にマッピングします。

t-SNEの理論は全く理解できていませんが、近い特徴を持つものを近くに、遠い特徴を持つものを遠くにマッピングしてくれる便利なツールと思っています。

size = 24
n = size * size
x_data, y_data = shuffle(data, target, n_samples=n)
x_pca = PCA(n_components=50).fit_transform(x_data/255)
embeddings = TSNE(perplexity=50, random_state=24680, verbose=2).fit_transform(x_pca)
embeddings = minmax_scale(embeddings)

fig, ax = plt.subplots(figsize=(12,12))

for i in range(10):
  ax.scatter(embeddings[y_data==i,0],embeddings[y_data==i,1],cmap='tab10',marker='o',alpha=0.7,label=labels[i])
  x_,y_ = np.median(embeddings[y_data==i,:],axis=0)
  txt = ax.text(x_,y_,labels[i],fontsize=30)
  txt.set_path_effects([
    patheffects.Stroke(linewidth=7, foreground="w"),
    patheffects.Normal()
  ])

ax.xaxis.set_ticks([])
ax.yaxis.set_ticks([])
ax.legend(loc='lower right')

plt.show()
[t-SNE] Computing 151 nearest neighbors...
[t-SNE] Indexed 576 samples in 0.005s...
[t-SNE] Computed neighbors for 576 samples in 0.034s...
[t-SNE] Computed conditional probabilities for sample 576 / 576
[t-SNE] Mean sigma: 3.304848
[t-SNE] Computed conditional probabilities in 0.031s
[t-SNE] Iteration 50: error = 66.0491257, gradient norm = 0.4408489 (50 iterations in 0.281s)
[t-SNE] Iteration 100: error = 66.5490036, gradient norm = 0.4279355 (50 iterations in 0.227s)
[t-SNE] Iteration 150: error = 66.1810837, gradient norm = 0.4408225 (50 iterations in 0.232s)
[t-SNE] Iteration 200: error = 66.5024948, gradient norm = 0.4372374 (50 iterations in 0.249s)
[t-SNE] Iteration 250: error = 67.7577744, gradient norm = 0.4174628 (50 iterations in 0.248s)
[t-SNE] KL divergence after 250 iterations with early exaggeration: 67.757774
[t-SNE] Iteration 300: error = 1.0015848, gradient norm = 0.0030262 (50 iterations in 0.203s)
[t-SNE] Iteration 350: error = 0.9373937, gradient norm = 0.0009318 (50 iterations in 0.157s)
[t-SNE] Iteration 400: error = 0.9023022, gradient norm = 0.0010213 (50 iterations in 0.169s)
[t-SNE] Iteration 450: error = 0.8871266, gradient norm = 0.0003354 (50 iterations in 0.172s)
[t-SNE] Iteration 500: error = 0.8833435, gradient norm = 0.0002266 (50 iterations in 0.160s)
[t-SNE] Iteration 550: error = 0.8798280, gradient norm = 0.0001704 (50 iterations in 0.172s)
[t-SNE] Iteration 600: error = 0.8746018, gradient norm = 0.0003532 (50 iterations in 0.161s)
[t-SNE] Iteration 650: error = 0.8552826, gradient norm = 0.0007609 (50 iterations in 0.156s)
[t-SNE] Iteration 700: error = 0.8477432, gradient norm = 0.0002363 (50 iterations in 0.176s)
[t-SNE] Iteration 750: error = 0.8456022, gradient norm = 0.0001718 (50 iterations in 0.156s)
[t-SNE] Iteration 800: error = 0.8452249, gradient norm = 0.0002175 (50 iterations in 0.163s)
[t-SNE] Iteration 850: error = 0.8443690, gradient norm = 0.0001080 (50 iterations in 0.172s)
[t-SNE] Iteration 900: error = 0.8439267, gradient norm = 0.0000835 (50 iterations in 0.159s)
[t-SNE] Iteration 950: error = 0.8437628, gradient norm = 0.0000937 (50 iterations in 0.172s)
[t-SNE] Iteration 1000: error = 0.8437957, gradient norm = 0.0000555 (50 iterations in 0.162s)
[t-SNE] KL divergence after 1000 iterations: 0.843796

よくみると、一つの文字で複数のマッピング場所があるものがあります(というかほとんどの文字がそうです)。

それぞれの座標に画像も表示します。

fig, ax = plt.subplots(figsize=(16,16))

source = zip(embeddings, x_data.reshape((-1,28,28)), y_data)

for pos, d, i in source:
  img = colorize(d, colors[i], 0.5)
  ab = offsetbox.AnnotationBbox(offsetbox.OffsetImage(img),0.03 + pos * 0.94,frameon=False)
  ax.add_artist(ab)

ax.xaxis.set_ticks([])
ax.yaxis.set_ticks([])

handles = [mlines.Line2D([0], [0], label=labels[i],
                         linewidth=0, marker='o', alpha=0.5,
                         markersize=7,markerfacecolor=colors[i],markeredgewidth=0)
           for i in range(10)]
ax.legend(loc='lower right',handles=handles)
plt.show()

次は、src-d/lapjvというモジュールを利用して、グリッド上に配列します。

grid = np.dstack(np.meshgrid(np.linspace(0, 1, size), np.linspace(0, 1, size))).reshape(-1, 2)
cost = cdist(grid, embeddings, 'sqeuclidean').astype('float32')
cost *= 1e7 / cost.max()

_, col_asses, _ = lapjv(cost)
grid_jv = grid[col_asses]

fig, ax = plt.subplots(figsize=(16,16))

for pos, d, i in zip(grid_jv, x_data.reshape((-1,28,28)), y_data):
  img = Image.fromarray(colorize(d, colors[i], 0.8), 'RGBA').resize((35, 35), Image.ANTIALIAS)
  ab = offsetbox.AnnotationBbox(offsetbox.OffsetImage(img),0.01+pos*0.98,frameon=False)
  ax.add_artist(ab)

ax.xaxis.set_ticks([])
ax.yaxis.set_ticks([])
ax.set_axis_off()

plt.show()

「き」は「畿」と「起」、「れ」は「礼」と「連」に分離しています。わかるでしょうか。

6. 単独の文字について表示する

ここからが本番です。

それぞれの文字について、学習データと試験データを288個ずつ無作為に抽出して、混在させて表示してみます。グリッド表示もします。

青が学習データ、黄が試験データです。

size = 24
n = size ** 2

for num in range(10):
    x_data_n, y_data_n = shuffle(data[target==num], target[target==num], n_samples=int(n/2))
    x_data_ntst, y_data_ntst = shuffle(Test_data[Test_target==num+10], Test_target[Test_target==num+10], n_samples=int(n/2))
    x_data_np = np.concatenate([x_data_n, x_data_ntst])
    y_data_np = np.concatenate([y_data_n, y_data_ntst])

    x_pca_n = PCA(n_components=50).fit_transform(x_data_np/255)
    embeddings_n = TSNE(perplexity=50, random_state=24680, verbose=0).fit_transform(x_pca_n)
    embeddings_n = minmax_scale(embeddings_n)

    #ここからくずし字散布図
    fig, ax = plt.subplots(figsize=(16,16))

    for pos, d, i in zip(embeddings_n, x_data_np.reshape((-1,28,28)), y_data_np):
      img = colorize(d, colors[i//10], 0.7)
      ab = offsetbox.AnnotationBbox(offsetbox.OffsetImage(img),0.03 + pos * 0.94,frameon=False)
      ax.add_artist(ab)
      ax.xaxis.set_ticks([])
      ax.yaxis.set_ticks([])

    handles = [mlines.Line2D([0], [0], label=labels[i],
                             linewidth=0, marker='o', alpha=0.5,
                             markersize=7,markerfacecolor=colors[i//10],markeredgewidth=0)
               for i in [num,num+10]]
    ax.legend(loc='lower right',handles=handles)
    plt.show()


    #ここからグリッド表示部
    grid_n = np.dstack(np.meshgrid(np.linspace(0, 1, size), np.linspace(0, 1, size))).reshape(-1, 2)
    cost_n = cdist(grid_n, embeddings_n, 'sqeuclidean').astype('float32')
    cost_n *= 1e7 / cost_n.max()
    _, col_asses_n, _ = lapjv(cost_n)
    grid_jv_n = grid_n[col_asses_n]

    fig, ax = plt.subplots(figsize=(16,16))

    for pos, d, i in zip(grid_jv_n, x_data_np.reshape((-1,28,28)), y_data_np):
      img = colorize(d, colors[i//10], 0.85)
      img = Image.fromarray(img,'RGBA').resize((35, 35), Image.ANTIALIAS)
      ab = offsetbox.AnnotationBbox(offsetbox.OffsetImage(img),0.01 + pos * 0.98,frameon=False)
      ax.add_artist(ab)

    ax.xaxis.set_ticks([])
    ax.yaxis.set_ticks([])
    ax.set_axis_off()

    plt.show()

それぞれの文字が字母ごとにクラスタリングされています(「れ」は「礼」と「連」など)。

また、文字によっては、学習データと試験データで字母の割合が大きく異なることがわかります。

わかりやすいのは「や」で、学習データでは「屋」、「夜」を字母とするものはほとんど含まれていませんが、試験データには多く含まれています。

7. おわりに

コンペの上位入賞者の方も予想されていましたが、学習データと試験データで、含まれる字体に差異があることがわかりました。

これだと、学習データで学習したモデルが試験データにそのまま働かず、CVよりLBが低くなることが予想されます。

また、コンペにおいては試験データを学習に取り込んだモデル(Pseudo Labeling?)が有利になりそうです。

ただ、自分には、試験データをモデルに取り込むというやり方にどうも違和感を覚えますので、こういったデータセットでは学習データと試験データでかたよりがない方がよいと思います。

(念のため申し上げておきますが、コンペの上位入賞者にケチをつけるつもりは全くありません。与えられた条件でベストの成果を出されたと思います。)

添付データ

  • TSNE_sep.ipynb?X-Amz-Expires=10800&X-Amz-Date=20240420T142433Z&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIP7GCBGMWPMZ42PQ
  • Favicon
    new user
    コメントするには 新規登録 もしくは ログイン が必要です。