読者です 読者をやめる 読者になる 読者になる

俵言

しがない社会人が書く、勉強とかゆるふわプロコンのこと

【jupyterで学ぶ】 ゼロから作るDeep Learning - 第5回:4章(その1)損失関数

はじめに

次の記事を書こう書こうと思いつつ、気付いたら2週間経ってました。何てこったい..書く気を取り戻したので再開します。

この「ゼロから作るDeep Learning」の勉強内容をひたすら書いていく記事も、遂に本題である学習の話に入っていきます。
3章まではニューラルネットワークの構造など導入的な意味合いが強かったと思うのですが、ここからはいかに自動で最適な重みを学習するのか、というニューラルネットワークの核となる部分です。僕の一番勉強したかったところなので気合入れて勉強して行こうと思います!

4章 ニューラルネットワークの学習

4章の導入では、SVMやKNNといった機械学習の手法は人が特徴量(データの注目すべき点)を作りこむ必要がある(すなわち人が介在しなければならない)けれど、ニューラルネットワークディープラーニング)は生データをそのまま学習させることができると書かれています。

個人的には「そうなの?」って思ったんですけど、よくよく考えるとこの本は画像認識を念頭に置いて書かれているので間違っていない気がします。「この画像に写ってるのは何か?」って話だと確かにそうなのかなって。(画像認識やったことないのでわかんないですが。)
ただ、言語処理をやるだとか、多種多様なデータを複合して何かやろうだとかなると、人の手がかなり介在する気がします。まあこれからの進展でなんでも出来るようになるかもしれませんけども。

4章も3章と同様に3つに分けて勉強していこうと思います。

今回は その1である損失関数のお話です。

損失関数

ニューラルネットワークではデータから最適な重みを探すための指標として、損失関数(loss function)と呼ばれるものを用いています。損失関数は、簡単に言うと NN の性能がどれくらい悪いかを示していて、こいつの値が小さいほど性能が良いと考えられるとのこと。
それで、本の中では損失関数を二つ挙げています。ひとつは二乗和誤差(Sum of Squared Error:SSE)、もうひとつが交差エントロピー誤差(Cross Entropy Error)です。今回はこれらを紹介しつつ、何故損失関数を使うのかというところを見ていきます。

二乗和誤差(Sum of Squared Error:SSE)

以下の式で表されます。(ここで、 y は NN の出力、 t y に対する正解を表します。)

 E = \displaystyle \frac{1}{2} \sum_k \left(y_k - t_k \right)^2

読んで字の如く、(出力と正解の各成分の差の)二乗和による誤差(そういう日本語だと僕は解釈してます。残差平方和の方がしっくりきますが。)です。

出力の各成分の正解との誤差を足し合わせる形なので、かなり分かりやすい誤差ですよね。 因みに絶対値ではなく二乗を使うのは統計的な根拠があるらしいです。(参考:最小二乗法

あと、本だと何故か「mean squared error」ってなってました。ソースコードの方もこれに準じてるし、正誤表にも載ってないけど定義は SSE なんですよね。何でだろう..

交差エントロピー誤差(Cross Entropy Error)

以下の式で表されます。二乗和誤差よりパッと見わかりにくいかもしれません。

 E = \displaystyle - \sum_k t_k \log \left(y_k \right)

この章での(もしかしたらこの本での?)前提として、 tone-hot vector(即ち、成分のうちひとつだけが1で他が0であるようなベクトル)、 y \displaystyle \sum_k y_k = 1 \ (0 \leq y_k \leq 1) となるベクトルとなっています。
なんかこれだけ書くと分かりにくいですが、要は多クラス分類の話で、 y_k は入力がクラス  k である確率であり、ベクトル  t の1が立っている場所は正解クラスを示します。なので正解のクラスを  k_c とすれば、


t_k =
\begin{cases}
1 \ (k = k_c) \\
0 \ (otherwise) \\
\end{cases}

となるので、実質は

 E\left(y,t \right) = \displaystyle - \left(1 * \log \left(y_{k_c} \right) + 0 * \sum_{k \neq k_c} \log \left(y_k \right) \right) = - \log \left(y_{k_c} \right)

となります。 0 \leq y_k \leq 1 より、 E = -\log(y_{k_c})は以下のようなグラフです。

## 図示させる
fig,ax = plt.subplots(1,1,figsize = (5,5))
y_k_c = np.linspace(0,1,1000)
E = - np.log(y_k_c)
ax.plot(y_k_c, E)
ax.set_xlim(-0,1.1); ax.set_ylim(0,5)
ax.set_xlabel("y_{k_c}"); ax.set_ylabel("Cross Entropy Error")
fig.savefig("ch04_log.png")

f:id:Tawara:20161114231217p:plain

見たら分かるとおり、 y_{k_c} が小さいほど交差エントロピー誤差は大きくなります。正解であるクラスとなる確率が小さいほど誤差が大きくなるので直感的に自然ですね。

ところでプロフェッショナルシリーズの深層学習だと、この交差エントロピー誤差を訓練データに対する尤度から算出してました。天下りよりもすっきりするかもしれません。

実装

まあ式をそのまま書くだけなので工夫も何もって感じですが。本当に本のままです。

def sum_of_squared_error(y,t):
    return 0.5 * np.sum((y-t)**2)

def cross_entropy_error(y,t):
    delta = 1e-7
    return -np.sum(t*np.log(y+delta))

y,t は一次元配列ですが、numpyの力でシンプルな書き方が出来ます。logの中身が0になると-infになって怒られるので、微小量を加えてうまく誤魔化すのは大事なテクニックですね。
因みに、SSE は回帰問題、交差エントロピー誤差は分類問題に用いることが一般的だそうです。

ミニバッチ学習

上で実装した損失関数は一個の訓練データに対するものでしたが、実際は沢山の訓練データに対して損失関数を適用し、その和(あるいは平均)を学習の指標とします。式で書くとこんな感じです。

E = \displaystyle  - \frac{1}{N} \sum_n^N \sum_k t_{nk} \log \left(y_{nk} \right)

ただデータがめちゃくちゃたくさんあると、全部の訓練データについて損失関数を計算するのは結構コストがかかっちゃいます。

そこで、訓練データの一部を取り出して損失関数を計算し、それによって学習を行うことを考えます。これをミニバッチ学習と呼ぶそうです。本の中だとミニバッチを取り出す操作を実際にやってみるのですが、前回使ったMnistの訓練データからランダムにデータを選択するだけなのでここで書くほどのコードではありません。

ただミニバッチ学習をする上で、numpyのデータから一部を選択する処理について本の中で高度な機能をさらっと使っていたので少し詳しく触れます。

Advanced Indexing の話

※ここで書くのはかなり細かい話です。「そこは遠慮したい」って方はこちら

numpy.ndarraypythonの通常のリストとは異なる要素へのアクセス方法を持っています。詳しくは Numpy の Indexing のページを参照ですが、ここでさらっと紹介。

その前に、共通の機能を見てみます。例えば以下の4 * 4 行列 を考えたとき、

A = np.arange(16).reshape(4,4)
print "A:\n", A.reshape(4,4)
< A:
< [[ 0  1  2  3]
<  [ 4  5  6  7]
<  [ 8  9 10 11]
<  [12 13 14 15]]

がんばれば二次元のリストでも表現できます。

B = [[i*4 + j for j in xrange(4)] for i in xrange(4)]
print "B:\n",B
print "\nBを行列っぽく表示"
for row in B:
    print row

< B:
< [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11], [12, 13, 14, 15]]
< 
< Bを行列っぽく表示
< [0, 1, 2, 3]
< [4, 5, 6, 7]
< [8, 9, 10, 11]
< [12, 13, 14, 15]

以下のようなアクセスは普通のリストと同じなので特に違和感ありません。

print "[0行目を表示]\nA[0]: {0}\nB[0]: {1}".format(A[0], B[0])
print "\n[0行1列目を表示]\nA[0][1]: {0}\nB[0][1]: {1}".format(A[0][1], B[0][1]) 
print "\n[0行目と2行目を表示]\nA[0:4:2]:\n{0}\nB[0:4:2]:\n{1}".format(A[0:4:2], B[0:4:2])

< [0行目を表示]
< A[0]: [0 1 2 3]
< B[0]: [0, 1, 2, 3]
< 
< [01列目を表示]
< A[0][1]: 1
< B[0][1]: 1
< 
< [0行目と2行目を表示]
< A[0:4:2]:
< [[ 0  1  2  3]
<  [ 8  9 10 11]]
< B[0:4:2]:
< [[0, 1, 2, 3], [8, 9, 10, 11]]

最後の一個飛ばしスライシングは少し特殊ですが、使えると便利です。(スライシングは他にも色々使い方がありますが割愛。)
こういった操作は Basic Slicing and Indexing と呼ばれるみたいです。まあリストと共通の使い方ですしね。

でもnumpy.ndarray はもうちょっと特殊なアクセス: Advanced Indexingができます。 大きく二つありますが、先に1章にも登場する Boolean array indexingを紹介します。真偽値( True/False )の array を渡すことで、True の部分だけ残す Indexing です。

bool_idx = np.array([False,True, False, True])
print A[bool_idx]

< [[ 4  5  6  7]
<  [12 13 14 15]]

Trueである1行目と3行目だけ残っていることが分かります。
この機能はかなり便利で、各行のデータに対してある条件を満たすものだけ残すといったことができます。例えば以下の条件を考えて、

## Aの行のうち、2番目と3番目の要素の積が6の倍数なものは?
ba = np.apply_along_axis(func1d=lambda a: a[2]*a[3] % 6 == 0, axis=1, arr=A)
print ba

< [ True  True False  True]

これをAに食わせると、

print A[ba]

< [[ 0  1  2  3]
<  [ 4  5  6  7]
<  [12 13 14 15]]

となります。Aの2行目:[8 9 10 11] は、 10 \times 11 \mod 6  \not \equiv 0 なので除外されます。numpy.apply_along_axis()は最近発見したのですが、特定の軸に対して関数を適用できるとても便利なやつです。これについては別の機会に書きます。

そして最後に紹介するのが Integer array indexing です。配列に配列を食わせるやり方で、こいつだけ結構特殊なのでくわしく書きます。
実は単純な例だけは1章にも登場していてます。以下の例です。

print A[np.array([0,1,3])]

< [[ 0  1  2  3]
<  [ 4  5  6  7]
<  [12 13 14 15]]

二次元配列 A の0,1,3 行目を取り出しています。

これだけ見ると、スライシングを個別にできるようになったみたいに見えます。でも実際はそうじゃない。自分の行の長さを超える配列を突っ込んだり、

print A[np.array([0,2,0,2,0,2])]

< [[ 0  1  2  3]
<  [ 8  9 10 11]
<  [ 0  1  2  3]
<  [ 8  9 10 11]
<  [ 0  1  2  3]
<  [ 8  9 10 11]]

複数の配列を食わせたりできます。

print A[np.array([2,0,3]),np.array([0,2,1])]

< [8 2 13]

というかそもそも、配列を複数食わせるのが一般的な形です。4章でいきなり複数食わせるやつが出てきたので「何じゃこりゃ!?」ってなりました。本では結果しか説明してないんですよね。(だからこの Advanced Indexing の節を書いてます。)

Integer array indexingでは、要素を取り出したい行列(ここでは A )の次元数(ここでは A の次元数 = 2)だけ配列を与えることが出来ます。そして、2次元配列に1次元配列を与える場合は、

「与えた i 番目の配列の j番目の要素が、結果の j 番目の要素の i 軸のインデックスになります。」

..自分で言っててこんがらがりました。上の例(A[np.array([2,0,3]), np.array([0,2,1])])で式展開みたいなことをすると、

A[np.array([2,0,3]), np.array([0,2,1])] = [A[2][0] A[0][2] A[3][1]] = [8 2 13]

と書けます。与えた0番目の配列[2 0 3]は結果の各要素の行のindexに、1番目の配列[0 2 1]は各要素の列のindexに対応しています。もう少し一般的に書くと、

A[B,C] = [A[B[0]][C[0]] A[B[1]][C[1]] ... A[B[N]][C[N]]]

となります。配列 B,C の大きさ N に制限は無く、与えた配列と同じ大きさのものが結果として返ります。ここが Integer array indexing の 最大の特徴だと思います。
Boolean array indexing は、ある配列から(条件を満たす)一部の要素を抜き出す Indexing でした。これに対し、Integer array indexing はある配列の要素を使って新しい配列を作る Indexing と言えます。

注意すべき点は、与える配列の数に制限があるのに対して形には制限が無いことです。言い換えると、与える配列の次元は何でも構いません。

例えば与える配列を3次元配列にすれば、

B = np.array([[[0,0],[1,1]],[[2,2],[3,3]]])
C = np.array([[[3,3],[2,2]],[[1,1],[0,0]]])

print "B:\n",B
print "\nC:\n",C

< B:
< [[[0 1]
<   [2 3]]
< 
<  [[3 2]
<   [1 0]]]
< 
< C:
< [[[0 3]
<   [1 2]]
< 
<  [[2 1]
<   [3 0]]]

2次元配列 A を元にして3次元配列が生成されます。

print A[B,C]

< [[[ 0  7]
<   [ 9 14]]
< 
<  [[14  9]
<   [ 7  0]]]

​このように、Integer array indexing は中々奥が深い機能です。本ではスペースの都合上省かれてるんだと思うのですが、python使う人は さらっとドキュメント見るのもいいんじゃないかなって思います。
ちなみに、Integer array indexing はもうちょい細かい話があるのですが、これ以上書くと更に本題から外れていくので割愛します。

なぜ損失関数を使うのか?

何の話してましたっけ?ああ、損失関数が今回のテーマでした。えらく寄り道してしまいました。
冒頭に書いたことを繰り返すと、NNの最適な重みを探すために NN の性能の悪さを示す損失関数を使います。でも、「そもそも性能の良し悪しなら精度を使えばよいのではないか?」という疑問が沸く人も居るはず。でもそれでは学習がうまくできないという話。

SSE の方は回帰問題ならそのまま精度みたいなものだと思うので、おそらく著者が言いたいのは多クラス分類問題(例題の手書き文字認識とか)の精度は使うべきではないって話みたいです。

例えば手書き文字認識の場合、100枚中何枚正しく認識できたかを精度にすると、その精度は不連続な値になります。テストデータが100枚なら、精度50%の次に良い精度は51%であり、精度50.5%はあり得ません。(1000枚の場合は50.5%があり得ますが、その場合は50.0%と50.1%の間に50.05%があり得ない)。以下はイメージ図です。実は性能(performance)が少しずつ上がっていても、切りのいい数字にならないと精度(precision)には反映されないという感じ。 f:id:Tawara:20161121235311p:plain

最適な重みを学習させる際には、重みを少しずつ変化させて一番性能が良くなるところを探します。ただその指標が不連続だと、重みを少し変化させても指標が変化しないので学習仕様がない。これが不連続な値(例:100中何枚正しく認識したか)を指標に使えない理由だそうです。

で、今回上げた SSE や交差エントロピー誤差は連続関数なのでOKってわけです。

まとめ

今回は損失関数の話だった..のですが、完全に Advanced Indexing 回でした。今回は損失関数紹介するだけだったので正直書くモチベーションが低く、いつのまにか書きかけで二週間も放置してました。完全にやらかした。

ただ、同時並行で勉強してる深層学習(機械学習プロフェッショナルシリーズ)の方が誤差逆伝播法まで来ちゃったので、そろそろ実装も進めないとやばいということで復帰しました。再出発ということで。

次回は偏微分によって損失関数を最小化する勾配法と、それを計算機上で実現する数値微分の話です。実装も含んでるしまあ楽しい回になるのではないでしょうか。ただ個人的な都合で今週中に誤差逆伝播に行きたいのでさらっと流すかもしれません。いやそれなら Advance Indexing とか構ってる場合かって話ですけどね。

ゼロから作るDeep Learning ―Pythonで学ぶディープラーニングの理論と実装

おまけ (2016/11/22 07:38に追記)

今回は本編のほとんどがおまけみたいなものだったのでおまけ書かないつもりでしたが、やっぱりもうちょい書きたいので更に詳しく書くことにしました。

Integer array indexing についてもうちょい詳しく

Integer array indexing は、要素を取り出す配列の次元数だけ配列を指定できます。例えば

A = np.array([[1,2],[3,4]])
print A

< [[1 2]
<  [3 4]]

なら二個まで指定可能で、その形状は自由です。

print A[np.array([0,0,0,0]),np.array([1,1,1,1])]

< [2 2 2 2]

ただ、本編で例として挙げたものは与える各配列の形状が全部一緒でしたが、実はその必要は無くて、

# 形状の異なるB,C
B = np.array([[0],[1],[0]])
C = np.array([1,0])
print "B:\n",B
print "C:\n",C
print "B's shape:", B.shape
print "C's shape:", C.shape

< B:
< [[0]
<  [1]
<  [0]]
< C:
< [1 0]
< B's shape: (3L, 1L)
< C's shape: (2L,)

以下のように異なる形状の配列でもOKです。

print A[B,C]

< [[2 1]
<  [4 3]
<  [2 1]]

なぜこれが出来るのかというと、前回僕を苦しめた numpyの便利機能:broadcast が働いているからです。配列の形を後ろから見ていって、勝手によしなにしてくれます。(詳しくは前回のbroadcastの節を参照。)
実際には、A[B,C]が実行されたとき、B,C はそれぞれ以下の B2, C2 に拡張されます。

B2 = np.array([[0,0],[1,1],[0,0]])
C2 = np.array([[1,0],[1,0],[1,0]])
print "B2:\n",B2
print "C2:\n",C2
< B2:
< [[0 0]
<  [1 1]
<  [0 0]]
< C2:
< [[1 0]
<  [1 0]
<  [1 0]]

Aに適用すると

print A[B2,C2]

< [[2 1]
<  [4 3]
<  [2 1]]

ちゃんと同じ結果になってますね。

ところで、本編で 「 Integer array indexing は配列を複数食わせるのが一般的な形だ」みたいなことを言いました。ドキュメントの方では、要素を取り出す配列の次元数と同じだけ配列を食わせるのを Purely integer array indexing と呼んでいます。 上で挙げた例も、2次元配列に配列を2個食わせるので purely です。
じゃあ、本で出てきた2次元配列に一個だけ配列を食わせる、

print A[np.array([0,1,0])]

< [[1 2]
<  [3 4]
<  [1 2]]

みたいなのは何なの?って話ですが、時間が無いのでまた今度ここに追加で書きます。

Integer array indexing についてもっと詳しく(2016/11/23に追記)

祝日で時間があるので続きを書きます。
Numpy の Indexing のドキュメントでは、上記の Purely integer array indexing の後に、Integer array indexing と通常の indexing を組み合わせる、Combining advanced and basic indexing が登場します。これの挙動が、purely の方とは全然異なるかつ難しいので注意が必要です。

例には以下の 2次元配列 A を使います。

A = np.arange(1,10).reshape(3,3)
print A1

< [[1 2 3]
<  [4 5 6]
<  [7 8 9]]

で、以下の

print A1[np.array([0,2])]

< [[1 2 3]
<  [7 8 9]]

なのですが、実は Integer array indexing と slicing の組み合わせ、

print A[np.array([0,2]),:] # 略さずに書くとA[np.array([0,2]),0:3]

< [[1 2 3]
<  [7 8 9]]

の略記です。ちなみに列方向に取り出したい場合は、

print A[:,np.array([0,2])]

< [[1 3]
<  [4 6]
<  [7 9]]

のように明記する必要があります。

まあ略記なのは良いとして、実際に何が起こってるのかがいまいちよくわかりません。ここで注目したいのは、与えた配列が1次元なのに対して、結果が 2次元配列だということです。
Purely integer array indexing では、基本的に結果の配列の形状は与えた配列の形状と同じになります。(与えた複数の配列の形状が異なる場合は、broadcastで拡張された後の形状になる)
シンプルな例として、試しに A に要素数2の1次元配列 B,C を与えると

B = np.array([0,2])
C = np.array([1,2])
print A[B,C]

< [2 9]

ちゃんと1次元配列が返ります。しかしながら A[np.array([0,2]),:] で返るのは2次元配列です。この時点で Purely と動きが異なることが分かります。

で、何が起きているのかをちょっと直感的に考えてみます。あくまでイメージなので、実際の処理とは全然異なることにご注意ください。
A[B,C] の場合は、結果の形状は B, Cと同じです。最初から形状を固定して、

A[B,C] = [ A[?][?]  A[?][?] ]

あとは結果の各要素(A[i][j]の形をしている) について、行方向のインデックス(i)は B 、列方向のインデックス(j)は C によって決定するというイメージ。

A[B,C] = [ A[B[0]][C[0]]  A[B[1]][C[1]] ] = [ A[0][1]  A[2][2] ] = [2 9]

次にA[B, :] = A[B, 0:3]ですが、これについてはまず B だけ先に考えます。

A[B, 0:3]

= [ A[B[0]][?]  A[B[1]][?] ]

= 

[ A[B[0]][?]    <- i = B[0]が確定

  A[B[1]][?] ]  <- i = B[1]が確定

次に、スライス 0:3 によって、この結果の下に次元が増えます。現時点の結果の配列の形状は(2,)ですが、これが(2,3)に変化。

A[B, 0:3] =

[ [ A[B[0]][0]  A[B[0]][1]  A[B[0]][2] ]   <- i = B[0]が確定していて、その下の次元が増える(広がる)

  [ A[B[1]][0]  A[B[1]][1]  A[B[1]][2] ] ] 

= 

[ [ A[0][0]  A[0][1]  A[0][2] ]

  [ A[2][0]  A[2][1]  A[2][2] ] ]

= 

[ [ 1  2  3 ]

  [ 7  8  9 ] ]

結果の各要素(A[i][j])について、A に与えた1個目の(配列B)が行のインデックス(i)、与えた2個目のもの(スライス 0:3)が列のインデックス(j)を決定しているのは同じです。ただ、Bの適用範囲がスライスによって広がっているというイメージです。

同じ流れで 今度は A[:, B] を考えてみると、最初のスライスでは一番上の次元の要素数が3とまではわかります。内部の形がどうなるか分かりませんが、結果の各要素(A[i][j] の形) の i は この時点で確定です。

A[:, B]

= [ [ ? ]  [ ? ]  [ ? ] ]   

= 

[ [ ? ]   <- i = 0 は確定

  [ ? ]   <- i = 1 は確定

  [ ? ] ]   <- i = 2 は確定

次に B を考えると、各行の中身が 要素数2の1次元配列と分かります。そして 0行目,1行目, 2行目それぞれにおいて、A[i][j]の j を Bが決めます。

A[:, B] = 

[ [ A[0][B[0]]  A[0][B[1]] ]  <- i = 0 が確定していてその下にBが入る

  [ A[1][B[0]]  A[1][B[1]] ]

  [ A[2][B[0]]  A[2][B[1]] ] ]

= 

[ [ 1  3 ]

  [ 4  6 ]

  [ 7  9 ] ]

あるいは、先にBを考えて、

A[0:3, B] = [ A[?][B[0]]  A[?][B[1]] ]

これのに次元が増える( (2,) → (3, 2) となる)と考えてもいいかもしれません。
Integer arrayを基準に考えて、それがスライスによって広がるというのが個人的なイメージです。

なお、与える配列 が2次元になった場合は更に複雑ですが、

D = np.array([[0,1],[1,2]])
print A[D,1:3]

< [[[2 3]
<   [5 6]]
< 
<  [[5 6]
<   [8 9]]]

これも先にInteger arrayを考えて、

A[D, 1:3] = 

[ [ A[D[0][0]][?]  A[D[0][1]][?] ]

  [ A[D[1][0]][?]  A[D[1][1]][?] ] ]

=

[ [ A[D[0][0]][?]  
  
    A[D[0][1]][?] ]

  [ A[D[1][0]][?]  
    
    A[D[1][1]][?] ] ]

この下に次元が増える( (2,2) → (2,2,2))と考えればまったく同じように考えられます。

A[D, 1:3] = 

[ [ [ A[D[0][0]][1]  A[D[0][0]][2] ]

    [ A[D[1][0]][1]  A[D[1][0]][2] ] ] 

  [ [ A[D[1][0]][1]  A[D[1][0]][2] ]

    [ A[D[1][1]][1]  A[D[1][1]][2] ] ] ]

= 

[ [ [ 2  3 ]

    [ 5  6 ] ]

  [ [ 5  6 ]
  
    [ 8  9 ] ] ]

与える配列の形状がどうなろうとも、Aに与える0番目のモノはあくまで結果の各要素(A[i][j]の形をしている)の0番目のインデックス(i)を、1番目のモノは結果の各要素の1番目のインデックス(j)を決定するのがポイントといえます。

お詫びとか

勇み足で「続きを書きます!」って言ってたらえらいことになってしまった。正直うまく説明できなくて悔しいです。あと、めんどくさがって図を使わなかったせいで非常に見にくい点については申し訳ない。
ドキュメントの方も例示でわかるっちゃ分かるけど「これだ!」って表現が無くてけっこう書いてて困りました。なんかすっきりした表現ができないんですよね。

実は今回はイメージを書いただけで、Combining advanced and basic indexing を完全に説明したわけではないです。Indexing を行いたい配列の次元が増えてくると複数の配列と複数のスライスを食わせたいときがあって、並び方で挙動が変わったりするのですがそれについては別の機会に書きます。

イメージは完全についたんですがうまく説明できないので、またうまく説明できるようになったら Advanced Indexing の記事を書こうと思います。多分 Advanced Indexingだけで2つくらい記事書けそう。