第4回 多次元データの処理

目次

  • はじめに
  • 多次元配列の基本
    • 要素の指定
    • reshape、ravel、転置
    • 統計
  • 実例:海面水温の時空間データ
    • データの読み込み
    • 等値線図(空間2次元のプロット)
    • 平均、偏差
    • 気候値
    • 等値線図(空間1次元・時間1次元のプロット)
  • ブロードキャスト

はじめに

前回は、KEOの時系列を例にNumPyの1次元配列の基本的な取り扱いを学びました。 一方、シミュレーション結果のようなデータの場合は空間2, 3次元に時間1次元が加わって、最大4次元のデータを扱うことになります。 NumPyの配列は多次元データの扱いにも長けており、データの切り出しや統計に便利な機能が備わっています。

15年にわたる北西太平洋域の海面水温の時系列データを例に、多次元データの取り扱いを学びましょう。 まずは、NumPyとmatplotlibをインポートします。

In [1]:
import numpy as np
import matplotlib.pyplot as plt

多次元配列の基本

多次元配列の例

1次元配列が数字N個を1直線に並べたもの(ベクトル)なのに対し、2次元の配列は数字がN×Mの格子状に並んだもの(行列)です。さらに、N×M×Lの格子状に並べば、3次元配列となります。後で説明するreshapeメソッドを使って、実例をお見せします。

In [2]:
a = np.arange(20).reshape((4,5)) # 4 x 5の2次元配列
print(a)
[[ 0  1  2  3  4]
 [ 5  6  7  8  9]
 [10 11 12 13 14]
 [15 16 17 18 19]]
In [3]:
b = np.arange(27).reshape((3,3,3)) # 3 x 3 x 3の3次元配列
print(b)
[[[ 0  1  2]
  [ 3  4  5]
  [ 6  7  8]]

 [[ 9 10 11]
  [12 13 14]
  [15 16 17]]

 [[18 19 20]
  [21 22 23]
  [24 25 26]]]

形状を表す属性

配列xの形状を知るとき、以下の3つの属性をよく使います。

  • x.ndim 次元数
  • x.shape 形状(例えばN×M×Lのとき、(N, M, L)というタプル)
  • x.size 合計要素数
In [4]:
print(a.ndim)
print(a.shape)
print(a.size)
2
(4, 5)
20
In [5]:
print(b.ndim)
print(b.shape)
print(b.size)
3
(3, 3, 3)
27

要素の指定

1つの要素を取り出す

リストや1次元配列では、0から数えてn番目の要素を指定するときはa[n]のようにしていました。 2次元配列では要素番号を2つ与え、1次元めがn番目、2次元めがm番目の要素をa[n,m]のように指定します。3次元以上の場合も同様です。

In [6]:
# 例:2次元の場合
print(a) # 上で定義した4x5配列。後ろの次元ほど内側のカッコで表示される
print(a[0,0]) # 第1次元が(0から数えて)0番目、第2次元が0番目の要素
print(a[0,1]) # 第1次元が(0から数えて)0番目、第2次元が1番目の要素
print(a[1,0]) # 第1次元が(0から数えて)1番目、第2次元が0番目の要素
print(a[1,4]) # 第1次元が(0から数えて)1番目、第2次元が4番目の要素
print(a[1,-1]) # 第1次元が(0から数えて)1番目、第2次元が最後から1番目の要素
[[ 0  1  2  3  4]
 [ 5  6  7  8  9]
 [10 11 12 13 14]
 [15 16 17 18 19]]
0
1
5
9
9

複数の要素を取り出す

複数要素を取り出すスライスを、次元ごとに指定することもできます。

In [7]:
print(a[1,:]) # 第1次元が1番目の、すべての要素(1次元配列として得られる)
[5 6 7 8 9]
In [8]:
print(a[1,1:4]) # 第1次元が1番目、第2次元が1番目から4番目の手前
[6 7 8]
In [9]:
print(a[:,3]) # 第2次元が3番目のすべての要素(1次元配列として得られる)
[ 3  8 13 18]

練習4-1

はじめに、次の通りに2次元配列xを作りなさい。

In [10]:
x = np.arange(48).reshape((8,6))
print(x)
[[ 0  1  2  3  4  5]
 [ 6  7  8  9 10 11]
 [12 13 14 15 16 17]
 [18 19 20 21 22 23]
 [24 25 26 27 28 29]
 [30 31 32 33 34 35]
 [36 37 38 39 40 41]
 [42 43 44 45 46 47]]

xから次の要素を得るには、どのように指定すればよいでしょうか。

  1. 3
  2. 20
  3. 40
  4. [24, 25, 26, 27, 28, 29]
  5. [0, 6, 12, 18, 24, 30, 36, 42]
  6. [[18, 19, 20, 21, 22, 23], [24, 25, 26, 27, 28, 29]]
  7. [[38, 39, 40, 41], [44, 45, 46, 47]]

reshape、ravel、転置

reshape:形状を組みなおす

メソッドとは配列に所属する関数のことで、配列名.メソッド名(引数)のように用いられるのでした。 ndarrayのもつreshapeメソッドを用いることで、配列を新しい形状に組みなおすことができます(要素数は同じでないといけません)。例を見てみましょう。

In [11]:
p = np.arange(30) 
In [12]:
print(p) # 0~29の数列を30要素の1次元配列に格納したもの
print(p.shape)
[ 0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
 24 25 26 27 28 29]
(30,)
In [13]:
q = p.reshape((6,5)) # (6,5)というタプル(リストみたいなもの)で形状を指定している
print(q) # pを6×5の2次元配列に格納したもの
print(q.shape)
[[ 0  1  2  3  4]
 [ 5  6  7  8  9]
 [10 11 12 13 14]
 [15 16 17 18 19]
 [20 21 22 23 24]
 [25 26 27 28 29]]
(6, 5)
In [14]:
r = p.reshape((2,3,5)) # (2,3,5)というタプルで形状を指定
print(r) # pを2×3×5の3次元配列に格納したもの
print(r.shape)
[[[ 0  1  2  3  4]
  [ 5  6  7  8  9]
  [10 11 12 13 14]]

 [[15 16 17 18 19]
  [20 21 22 23 24]
  [25 26 27 28 29]]]
(2, 3, 5)

ravel:形状を1次元にする

上の例とは逆に、多次元配列を1列に並べなおして1次元配列にするときにはravelメソッドを用います。1次元に並べなおすときにはa[0,0], a[0,1], ..., a[0,n], a[1,0], a[1,1], ..., a[1,n], ...のように、後ろの次元を小さい単位(一の位)とし、前の次元を大きい単位(十の位、百の位・・・)のように並べられます。

In [15]:
print(r)
print(r.ravel()) # rを1次元に並べたもの。r[0,0,0~4], r[0,1,0~4], r[0,2,0~4], r[1,0,0~4]...
[[[ 0  1  2  3  4]
  [ 5  6  7  8  9]
  [10 11 12 13 14]]

 [[15 16 17 18 19]
  [20 21 22 23 24]
  [25 26 27 28 29]]]
[ 0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
 24 25 26 27 28 29]

転置

配列の転置a.Tとすることで得られます。例えば2次元配列でb=a.Tのとき、b[i,j]a[j,i]に等しくなり、bの形状はaの形状の1,2次元目の順序を入れ替えたものとなります。
()が無いことからわかる通り、転置配列は厳密にはメソッドではなく属性です。

In [16]:
print(q)
print(q.shape)
[[ 0  1  2  3  4]
 [ 5  6  7  8  9]
 [10 11 12 13 14]
 [15 16 17 18 19]
 [20 21 22 23 24]
 [25 26 27 28 29]]
(6, 5)
In [17]:
s = q.T
print(s)
print(s.shape)
[[ 0  5 10 15 20 25]
 [ 1  6 11 16 21 26]
 [ 2  7 12 17 22 27]
 [ 3  8 13 18 23 28]
 [ 4  9 14 19 24 29]]
(5, 6)

最後に注意として、reshape, ravel, Tのメソッドや属性は配列そのものを上書き更新するわけではなく、操作を施したものを返していることに気をつけましょう。すぐ上の例でも、q.Tsに代入することでsqの転置となっていますが、q自体はもともとのままです。

統計

特定の次元について統計を取る

1次元配列のときにも紹介した通り、ndarrayには平均・分散・最大最小などの統計量を計算できるメソッドがありました。 多次元配列でもこれらのメソッドはそのまま使えますが、引数に次元の番号を与えることで特定の次元に関する統計も取ることができます。

In [18]:
print(a)
[[ 0  1  2  3  4]
 [ 5  6  7  8  9]
 [10 11 12 13 14]
 [15 16 17 18 19]]
In [19]:
print(a.sum()) # 全体の合計をとり、定数で返す
190
In [20]:
print(a.sum(0)) # 1次元めの方向に和をとり、1次元配列で返す
[30 34 38 42 46]
In [21]:
print(a.sum(1)) # 2次元めの方向に和をとる、1次元配列で返す
[10 35 60 85]
In [22]:
print(b) # 3x3x3のルービックキューブのような配列
[[[ 0  1  2]
  [ 3  4  5]
  [ 6  7  8]]

 [[ 9 10 11]
  [12 13 14]
  [15 16 17]]

 [[18 19 20]
  [21 22 23]
  [24 25 26]]]
In [23]:
print(b.mean()) # 全体の平均をとり、定数で返す
13.0
In [24]:
print(b.mean(0)) # 1次元めの方向に平均をとり、2次元配列で返す
[[ 9. 10. 11.]
 [12. 13. 14.]
 [15. 16. 17.]]
In [25]:
print(b.mean((1,2))) # 2次元めと3次元めの方向に平均をとり、1次元配列で返す。複数の次元を指定するときは、タプルで指定する
[ 4. 13. 22.]

練習4-2

まず、次の通りに配列yを定義しなさい。(参考:第3回練習問題)
これは、あるマンションの部屋ごとの居住者数を表しているとします。マンションは7階建てで、各階に8部屋あります。
yの第1次元は階(1階は0, 2階は1, ..., 7階が6)を、第2次元は部屋番号(1号室が0, 2号室が1, ..., 8号室が7)を表しています。

In [26]:
rng = np.random.default_rng(seed=54321)
y = rng.integers(low=1, high=7, size=56).reshape((7,8))

以下を求めなさい。

  1. 2階3号室に住んでいる人数(5)
  2. マンション全体の居住者数の合計(201)
  3. マンション全体の一部屋の居住者数の平均(3.589...)
  4. 各階の居住者数の合計(31 31 25 25 36 26 27)
  5. 各階の居住者数の最大(6 6 5 6 6 5 6)
  6. 1号室に住む人数の合計(22)
  7. 4階以上に住む1~4号室の人数の合計(59)
  8. 4階以上に住む5~8号室の人数の合計(55)

練習4-3

n個のサイコロを振って出た目の平均を$\bar{X}$とすると、$\bar{X}$の期待値は3.5ですが、その標準偏差は$\sqrt{n}$に比例して小さくなります。そのことを確かめましょう。
n個のサイコロを振って$\bar{X}$を求める操作を100回行い、その100個の$\bar{X}$の平均値・標準偏差を出力します。

まず、練習4-2と同様に1から6の数列を返す乱数生成器rngを次のように定義します。(今回はseedなしで行きましょう)

In [27]:
rng = np.random.default_rng()

整数nを引数とし次のような操作を行う関数mean_and_stdを定義しなさい。

  1. rng.integers(low=1, high=7, size=n*100)で、1~6の乱数が100n個連なった配列を得る
  2. 上の配列をn×100の2次元配列に変形する
  3. 大きさnの次元に平均をとり、大きさ100の1次元配列を得る
  4. 上の配列の平均と標準偏差をprintする

mean_and_stdを$n=10, 100, 1000, 10000, 100000$について呼び、確かに標準偏差が$\sqrt{n}$に比例して小さくなっていることを確かめましょう。

実例:海面水温の時空間データ

データの読み込み

それでは、実際の多次元データを使ってデータ解析をしてみましょう。 今回は、ヨーロッパ中期予報センター(ECMWF)が制作した再解析プロダクトERA5の海面水温データを用います。

今回使うデータ

再解析データとは、観測と数値シミュレーションのデータの持つ以下の長所・短所を補い合うため、これらのデータを組み合わせて得られた推測値です。

  • 観測:現実を反映しているが、時間的・空間的に飛び飛びにしかない
  • 数値シミュレーション:時間的・空間的に抜けがないが、現実からかけ離れるおそれがある

数値シミュレーションと同様、空間2or3次元+時間1次元の多次元データとして得られますので、今回紹介した多次元配列の処理が必要となります。

今回用いるデータは以下のような物です。

  • 東経120~180度、北緯20~60度の北西太平洋域、0.25度ごと
  • 2007年1月から2021年12月まで、毎月の平均海面水温データ

こちらからダウンロードし、保存してください。
https://yasushifujiwara.github.io/dataanalysistutorial/data_ERA5/sst_nwpac.nc

NetCDF形式とは

今回のデータは拡張子.ncをもつ、NetCDF形式で保存されたバイナリデータです。NetCDFの特徴はすぐ下で述べますが、大気や海洋分野で非常によく使われるデータ形式です。
以下の方法で読み込んでみましょう。

In [28]:
import netCDF4
data = netCDF4.Dataset(r"C:\Users\yfuji\Desktop\lesson\data\data_ERA5\sst_nwpac.nc")

NetCDF形式の特徴として、自己記述的であることが挙げられます。
例えば(今回の場合)時間1次元・空間2次元の格子状に格納されたSSTデータだけでは、それぞれの点がいつ・どの経度・どの緯度の情報なのかわかりません。
NetCDFデータは目的のデータに加え、緯度・経度・時間といった軸データも同時に含んでいます。
読み込んだdata変数を出力することで表示される、以下の情報のなかのdimensionsvariablesに注目してみましょう。

In [29]:
print(data)
<class 'netCDF4._netCDF4.Dataset'>
root group (NETCDF3_64BIT_OFFSET data model, file format NETCDF3):
    Conventions: CF-1.6
    history: 2022-12-02 00:06:36 GMT by grib_to_netcdf-2.25.1: /opt/ecmwf/mars-client/bin/grib_to_netcdf.bin -S param -o /cache/data8/adaptor.mars.internal-1669939595.3483937-17551-5-9302fcdf-471e-49fe-96a8-c28ac22d808f.nc /cache/tmp/9302fcdf-471e-49fe-96a8-c28ac22d808f-adaptor.mars.internal-1669939544.4876807-17551-11-tmp.grib
    dimensions(sizes): longitude(241), latitude(161), time(180)
    variables(dimensions): float32 longitude(longitude), float32 latitude(latitude), int32 time(time), int16 sst(time, latitude, longitude)
    groups: 

dimensionsにはこのファイルの中で登場する次元と、それぞれの次元が持つ点の数が表記されています。今回の場合、経度longitudeが241点、緯度latitudeが161点、時間timeが180点あることがわかります。
variablesには、このファイルに含まれているデータと、そのデータがどの次元を持つのかが表記されています。 軸データであるlongitude, latitude, timeは自分自身の次元を持つ1次元配列で、海面水温sstは時間・緯度・経度の順に次元を持っていることがわかります。

データの中身へのアクセス

さて、それではそれぞれのデータにアクセスしてみましょう。以下のようにすることで、それぞれのデータをndarrayとほぼ同じ形(厳密にはndarrayを拡張したmaskedarray)で取得できます。

In [30]:
lon = data["longitude"][:]
lat = data["latitude"][:]
time = data["time"][:]
sst = data["sst"][:,:,:]
In [31]:
# masked arrayを普通のndarrayに戻す。陸地の値をnanにする
sst = sst.filled(np.nan)
In [32]:
lon # 経度
Out[32]:
masked_array(data=[120.  , 120.25, 120.5 , 120.75, 121.  , 121.25, 121.5 ,
                   121.75, 122.  , 122.25, 122.5 , 122.75, 123.  , 123.25,
                   123.5 , 123.75, 124.  , 124.25, 124.5 , 124.75, 125.  ,
                   125.25, 125.5 , 125.75, 126.  , 126.25, 126.5 , 126.75,
                   127.  , 127.25, 127.5 , 127.75, 128.  , 128.25, 128.5 ,
                   128.75, 129.  , 129.25, 129.5 , 129.75, 130.  , 130.25,
                   130.5 , 130.75, 131.  , 131.25, 131.5 , 131.75, 132.  ,
                   132.25, 132.5 , 132.75, 133.  , 133.25, 133.5 , 133.75,
                   134.  , 134.25, 134.5 , 134.75, 135.  , 135.25, 135.5 ,
                   135.75, 136.  , 136.25, 136.5 , 136.75, 137.  , 137.25,
                   137.5 , 137.75, 138.  , 138.25, 138.5 , 138.75, 139.  ,
                   139.25, 139.5 , 139.75, 140.  , 140.25, 140.5 , 140.75,
                   141.  , 141.25, 141.5 , 141.75, 142.  , 142.25, 142.5 ,
                   142.75, 143.  , 143.25, 143.5 , 143.75, 144.  , 144.25,
                   144.5 , 144.75, 145.  , 145.25, 145.5 , 145.75, 146.  ,
                   146.25, 146.5 , 146.75, 147.  , 147.25, 147.5 , 147.75,
                   148.  , 148.25, 148.5 , 148.75, 149.  , 149.25, 149.5 ,
                   149.75, 150.  , 150.25, 150.5 , 150.75, 151.  , 151.25,
                   151.5 , 151.75, 152.  , 152.25, 152.5 , 152.75, 153.  ,
                   153.25, 153.5 , 153.75, 154.  , 154.25, 154.5 , 154.75,
                   155.  , 155.25, 155.5 , 155.75, 156.  , 156.25, 156.5 ,
                   156.75, 157.  , 157.25, 157.5 , 157.75, 158.  , 158.25,
                   158.5 , 158.75, 159.  , 159.25, 159.5 , 159.75, 160.  ,
                   160.25, 160.5 , 160.75, 161.  , 161.25, 161.5 , 161.75,
                   162.  , 162.25, 162.5 , 162.75, 163.  , 163.25, 163.5 ,
                   163.75, 164.  , 164.25, 164.5 , 164.75, 165.  , 165.25,
                   165.5 , 165.75, 166.  , 166.25, 166.5 , 166.75, 167.  ,
                   167.25, 167.5 , 167.75, 168.  , 168.25, 168.5 , 168.75,
                   169.  , 169.25, 169.5 , 169.75, 170.  , 170.25, 170.5 ,
                   170.75, 171.  , 171.25, 171.5 , 171.75, 172.  , 172.25,
                   172.5 , 172.75, 173.  , 173.25, 173.5 , 173.75, 174.  ,
                   174.25, 174.5 , 174.75, 175.  , 175.25, 175.5 , 175.75,
                   176.  , 176.25, 176.5 , 176.75, 177.  , 177.25, 177.5 ,
                   177.75, 178.  , 178.25, 178.5 , 178.75, 179.  , 179.25,
                   179.5 , 179.75, 180.  ],
             mask=False,
       fill_value=1e+20,
            dtype=float32)
In [33]:
sst.shape # SST配列の形状
Out[33]:
(180, 161, 241)

数値の意味を調べる

ある地点の海面水温を覗いてみましょう。適当に、時間0番目, 緯度80番目、経度120番目の点を見てみます(チェックする箇所はどこでも問題ありません。)
その時間・緯度・経度がどこを指しているのかも、time, lat, lonの配列の同番号から調べてみます。

In [34]:
print(sst[0,80,120]) # SST
print(time[0]) # 時間
print(lat[80]) # 緯度
print(lon[120]) # 経度
284.49854027875864
937944
40.0
150.0

緯度・経度は北緯40度・東経150度だと想像できますが、SSTと時間が何の数字なのかよくわかりませんね。 自己記述的であるNetCDFファイルには、それぞれの数値がどんな単位で格納されているのかの説明も含まれています。
元データdata変数に立ち戻り、以下のように調べてみましょう。

In [35]:
print(data["sst"])
<class 'netCDF4._netCDF4.Variable'>
int16 sst(time, latitude, longitude)
    scale_factor: 0.0005347600993964873
    add_offset: 287.9616466824503
    _FillValue: -32767
    missing_value: -32767
    units: K
    long_name: Sea surface temperature
unlimited dimensions: 
current shape = (180, 161, 241)
filling on

この中で、unitsに注目しましょう。今回の場合、単位(unit)はKであることがわかります。 上空で気温が非常に低くなる大気分野では℃にこだわらずKを使うのが便利なことが多いのですが、最低水温が結氷温度(-2℃くらい)で制限される海洋分野では℃標記のほうが用いられることが多いです。ここではSSTを℃表記で扱うことにしましょう。

In [36]:
sst = sst - 273.15 # KからCelciusへ
print(sst[0,80,120])
11.348540278758662

さて、次は時間937944が何を表すのかを調べましょう。

In [37]:
print(data["time"])
<class 'netCDF4._netCDF4.Variable'>
int32 time(time)
    units: hours since 1900-01-01 00:00:00.0
    long_name: time
    calendar: gregorian
unlimited dimensions: 
current shape = (180,)
filling on, default _FillValue of -2147483647 used

unitsを見てみると、「1900年1月1日0時0分0秒から数えた時間(hour)」と書いてあります。つまり、時間937944は「1900年1月1日0時0分0秒から937944時間後」を表します。

datetimeモジュールを使って日付を扱う

これが具体的にどの日を表すのか計算するのは、月ごとの日数が違ったりうるう年を考慮したりでなかなか面倒なのですが、現代は便利なものでネットですぐ調べられます(答え:2007年1月1日0時)。
また、以前存在だけ紹介したdatetimeモジュールを使ってもすぐ調べられます。参考までに方法を紹介します。

In [38]:
from datetime import datetime, timedelta
# datetimeはある日時、timedeltaは日時の差を表す
# datetime + timedelta -> datetime
# timedelta + timedelta -> timedelta
# datetime + datetime -> エラー

print(937944 * timedelta(hours=1) + datetime(1900, 1, 1))
2007-01-01 00:00:00

等値線図(空間2次元のプロット)

基本の描き方

それでは、いよいよ海面水温データの中身を図示しましょう。
sstは時間1次元・空間2次元の3次元配列ですが、例えばsst[0,:,:]などのようにある時間を切り出して、空間2次元の平面図を描くのが一番わかりやすいと思います。
2次元的なデータ分布を平面図に描くには、同じ値の点を線で結んだ(あるいは同じ色で塗った)等値線図を用いるのが有力な方法です。matplotlibの機能で、等値線図を描いてみましょう。

等値線図を描く基本の関数はplt.contour(線を引く)またはplt.contourf(色を塗る)です。基本的な使い方はplt.contourf(X, Y, Z)で、Zが描こうとしているデータの入った2次元配列、XYZ同じ形状の配列で、Zの格子のx座標, y座標を表します。 長方形状に格子が配置されている場合、2次元配列X, Ynp.meshgrid関数を用いることで1次元配列から生成できます。

In [39]:
# 2次元の位置座標配列を生成。
Lon, Lat = np.meshgrid(lon, lat) # SSTの配列の次元がlat, lonの順の場合。
# Lon, Lat = np.meshgrid(lon, lat, indexing="ij") # SSTの配列の次元がlon, latの順の場合。
In [40]:
print(lat.shape)
print(lat)
print(Lat.shape)
print(Lat)
(161,)
[60.   59.75 59.5  59.25 59.   58.75 58.5  58.25 58.   57.75 57.5  57.25
 57.   56.75 56.5  56.25 56.   55.75 55.5  55.25 55.   54.75 54.5  54.25
 54.   53.75 53.5  53.25 53.   52.75 52.5  52.25 52.   51.75 51.5  51.25
 51.   50.75 50.5  50.25 50.   49.75 49.5  49.25 49.   48.75 48.5  48.25
 48.   47.75 47.5  47.25 47.   46.75 46.5  46.25 46.   45.75 45.5  45.25
 45.   44.75 44.5  44.25 44.   43.75 43.5  43.25 43.   42.75 42.5  42.25
 42.   41.75 41.5  41.25 41.   40.75 40.5  40.25 40.   39.75 39.5  39.25
 39.   38.75 38.5  38.25 38.   37.75 37.5  37.25 37.   36.75 36.5  36.25
 36.   35.75 35.5  35.25 35.   34.75 34.5  34.25 34.   33.75 33.5  33.25
 33.   32.75 32.5  32.25 32.   31.75 31.5  31.25 31.   30.75 30.5  30.25
 30.   29.75 29.5  29.25 29.   28.75 28.5  28.25 28.   27.75 27.5  27.25
 27.   26.75 26.5  26.25 26.   25.75 25.5  25.25 25.   24.75 24.5  24.25
 24.   23.75 23.5  23.25 23.   22.75 22.5  22.25 22.   21.75 21.5  21.25
 21.   20.75 20.5  20.25 20.  ]
(161, 241)
[[60.   60.   60.   ... 60.   60.   60.  ]
 [59.75 59.75 59.75 ... 59.75 59.75 59.75]
 [59.5  59.5  59.5  ... 59.5  59.5  59.5 ]
 ...
 [20.5  20.5  20.5  ... 20.5  20.5  20.5 ]
 [20.25 20.25 20.25 ... 20.25 20.25 20.25]
 [20.   20.   20.   ... 20.   20.   20.  ]]
In [41]:
# 時間0について図を描く
plt.contourf(Lon, Lat, sst[0,:,:], # 時間0について、色塗り等値線図を描く
             levels=31,             # 色分けは30段階(色の区切りが31個)で行う
             cmap="plasma"             # 色調に"plasma"を用いる
            ) 
plt.colorbar() # カラーバーを図の右側に表示する
Out[41]:
<matplotlib.colorbar.Colorbar at 0x1c794eec5e0>

南から北に水温が下がっていたり、黒潮が暖水を運んでいる様子が見て取れますね。
陸地が白く塗りつぶされていますが、これは陸地の点のsst配列にnanが入っているからです。より地図っぽくするには海岸線を線で引いてやるとよいのですが、ここではそこまで踏み入らないことにします。

また、levels引数に色分けの個数(あるいは線の本数)を表す整数を与えています。こうすると色塗りの温度範囲を自動で判別してくれますが、自分で色塗り範囲を指定したいときにはlevels色を区切る温度を表す1次元配列を与えることもできます(違うデータを比較するときは色範囲を統一するべきなのでよく使います)。

cmap引数には、色調列の名前を与えられます。matplotlibで使える色調列はこちらなどから見られます。
余談ですが、よく使われる虹色の"jet"は前線域の誤認につながったり色覚異常の方に見えづらいことから使うのを避けるべきとされています。(参考:https://tos.org/oceanography/assets/docs/29-3_thyng.pdf
誰にでも見やすく、データの意味をよく伝える色調列を選ぶようにしましょう。

練習4-4

上を参考に、適当な時間について海面水温の分布図を描きなさい。

  • 時間0は2007年1月、時間1は2007年2月...となっています。自分が描いているのは何年何月の図ですか?
  • levelsに与える数を変えると、見え方はどのように変わるでしょうか。どの数を選ぶのが見やすいでしょうか。

関数を使って楽をする

違う時刻の図を見たいときにいちいち上のコードを書き直すのは面倒なので、図を描く関数を作ってしまうと楽です。 datetimeモジュールも駆使して、日時入りの図をつくる関数を定義してみます。

In [42]:
def draw_sst(n):
    lev = np.linspace(-2, 33, 36) # -2度から33度まで、1度区切りの配列
    plt.figure() # 新しいキャンバスを立ち上げる; 複数枚図を描くとき必要
    plt.contourf(Lon, Lat, sst[n,:,:], # 時間nについて、色塗り等値線図を描く
                 levels=lev,                # 色分けはlevに従って行う
                 cmap="plasma"              # 色調に"plasma"を用いる
                ) 
    plt.colorbar() # カラーバーを図の右側に表示する
    plt.title(time[n] * timedelta(hours=1) + datetime(1900, 1, 1)) # 日時を図タイトルに表示する
    plt.show() # 図を表示する; 複数枚図を描くとき必要

draw_sst(169)
draw_sst(175)

平均、偏差

平均海面水温分布

当然ながらSST分布は日々変化します。黒潮続流域の蛇行や渦は常在しているわけではありません。 これらを取り除いて、平均 (mean) のSST分布を調べてみましょう。
sst[時間, 緯度, 経度]の次元を持つ3次元配列です。1つ目の次元についての平均(次元は0番目から数えるので0を与える)をとることで、長期間の平均的なSST分布が[緯度, 経度]の2次元配列として取得できます。

In [43]:
sst_mean = sst.mean(0)
lev = np.linspace(-2, 33, 36)
plt.contourf(Lon, Lat, sst_mean,   # sst_meanについて、色塗り等値線図を描く
            levels=lev,            # 色分けは上で定義したlevに従い行う
            cmap="plasma"             # 色調に"jet"を用いる
            ) 
plt.colorbar() # カラーバーを図の右側に表示する
Out[43]:
<matplotlib.colorbar.Colorbar at 0x1c7951e12e0>

渦が取り除かれ、平均的でなだらかな水温分布が見えてきました。
基本的には南から北へと水温が下がっていきますが、黒潮や対馬海流に乗って周囲より暖かい水が北上している様子が見えます。つまり、これらの海流は常在している特徴と考えられるでしょう。

平均からのズレ

さて、平均的な描像がわかったところで今度は偏差 (anomaly)、すなわち平均からのズレを図にしてみましょう。
例えば、2017年以降のデータなら黒潮大蛇行の特徴が捉えられるかもしれません。

In [44]:
def draw_sst_a1(n):
    lev = np.linspace(-17,17,35)
    plt.figure() # 新しいキャンバスを立ち上げる; 複数枚図を描くとき必要
    plt.contourf(Lon, Lat, sst[n,:,:]-sst_mean, # 時間nについて、色塗り等値線図を描く
                 levels=lev,                         # 色分けは1度ごとに行う
                 cmap="seismic"                      # 色調に"seismic"を用いる
                ) 
    plt.colorbar() # カラーバーを図の右側に表示する
    plt.title(time[n] * timedelta(hours=1) + datetime(1900, 1, 1)) # 日時を図タイトルに表示する
    plt.show() # 図を表示する; 複数枚図を描くとき必要
    
draw_sst_a1(133)
draw_sst_a1(139)

・・・さて、これは「通常からのズレ」をよく表しているのでしょうか?そもそも通常とは?

気候値

平均からの偏差の図を見ると、夏は単に赤っぽく、冬は単に青っぽくなってしまいました。これは年間平均からの偏差を調べたからで、当然夏は平均より高く、冬は平均より低く出てしまいます。
ですが、多くの場合見たいのはそういった季節の違いによる差ではなく、夏に水温が高いのも冬に水温が低いのも「通常」だと考えるのが自然です。

このような背景から、大気・海洋の分野では基準的な状態として「同じ月の長期平均(例えば過去30年間の1月の平均)」がよく用いられます。これを気候値と呼びます。
ここでは15年分のデータを使っていますので、「15年分の1月の平均」「15年分の2月の平均」...「15年分の12月の平均」を気候値として求めましょう。
その結果の配列は(時間12点, 緯度161点, 経度241点)の配列となるはずです。

さて、この計算はどのようになされるでしょうか。

練習4-5

sst (時間180点, 緯度161点, 経度241点)を月ごとに平均し、(時間12点, 緯度161点, 経度241点)の形状を持つ気候値の配列sst_climを作りなさい。

以下で3つの解答例を示しますが、これまでの知識で十分にできることですのでまずは自分で考えてみましょう。

気候値描画の関数

解答例の前に、気候値ssh_climを図示する関数を定義してしまいます。

In [45]:
def draw_sst_clim(month):
    # monthは何月か
    # sst_climは0: 1月、1: 2月、...、11: 12月なので1個ずつずれてる
    month_name = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", 
                  "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
    
    lev = np.linspace(-2, 33, 36) # -2度から33度まで、1度区切りの配列
    
    plt.figure() # 新しいキャンバスを立ち上げる; 複数枚図を描くとき必要
    plt.contourf(Lon, Lat, sst_clim[month-1,:,:], # 時間nについて、色塗り等値線図を描く
            levels=lev,               # 色分けはlevに従って行う
            cmap="plasma"             # 色調に"plasma"を用いる
            ) 
    plt.colorbar() # カラーバーを図の右側に表示する
    plt.title(month_name[month-1] + " climatology") # 月名を図タイトルに表示する。"climatology"は気候値のこと
    plt.show() # 図を表示する; 複数枚図を描くとき必要

練習4-5の解答例

In [46]:
# 解答例1: forループを用いて、時間軸12個とばしの平均をとる
sst_clim = np.zeros((12, 161, 241)) # 入れ物をつくる

for n in range(12):
    sst_clim[n,:,:] = sst[n::12,:,:].mean(0)
    # n=0なら 0, 12, 24, ...
    # n=1なら 1, 13, 25, ...
In [47]:
# 解答例2: forループとマスクを用いて、月の通し番号を12で割った余りごとに平均をとる
sst_clim = np.zeros((12, 161, 241)) # 入れ物をつくる
nmonth = np.arange(180) # 月の通し番号, 0からスタート

for n in range(12):
    mask = (nmonth % 12 == n) # 月番号を12で割った余りがn
    sst_clim[n,:,:] = sst[mask,:,:].mean(0)
In [48]:
# 解答例3: reshapeで時間軸を総月数180から年数15x月数12に変形して(折りたたんで)、年数方向に平均をとる
sst_clim = sst.reshape((15, 12, 161, 241)).mean(0)
In [49]:
draw_sst_clim(2) # 2月
draw_sst_clim(8) # 8月

気候値からの偏差を描画する

「通常の状態」として気候値を求めたところで、改めて「偏差」を描く関数を定義しましょう。
通し番号nのデータについて、同月の気候値からの偏差を求めます。

In [50]:
def draw_sst_a2(n):
    lev = np.linspace(-5,5,21)
    plt.figure() # 新しいキャンバスを立ち上げる; 複数枚図を描くとき必要
    plt.contourf(Lon, Lat, sst[n,:,:]-sst_clim[n%12,:,:], # 時間nについて、色塗り等値線図を描く
                 levels=lev,                # 色分けはlevに従って行う
                 cmap="seismic"             # 色調に"seismic"を用いる
                ) 
    plt.colorbar()                     # カラーバーを図の右側に表示する
    plt.title(time[n] * timedelta(hours=1) + datetime(1900, 1, 1)) # 日時を図タイトルに表示する
    plt.show() # 図を表示する; 複数枚図を描くとき必要

では、先ほどと同様に2018年2月と8月の平年からの偏差を見てみましょう。

In [51]:
draw_sst_a2(133)
draw_sst_a2(139)

先ほどは季節変化で見えにくくなってしまっていた、様々な構造が見えています。
2月には東海・近畿南岸に大きな冷水塊が見えています。これは黒潮が蛇行し、その北側に普段はない冷水が位置しているから見えているものです。
また、2月と8月を比べると2月は数百km(数度)規模の構造が目立つのに対し、8月は数千km(数十度)規模の正負の構造が目立っています。
前者は海洋の中規模渦に起因する水温変動なのに対し、後者は大気の気圧配置に関連しています。夏は強い季節躍層が発達し、海洋内部の状態よりも大気の状態に左右されやすくなるのではと推測できます。

等値線図(時間1次元・空間1次元のプロット)

空間分布の時間変化を見るために

ここまでは、プロットする時間を1つ選んで、空間2次元の等値線図を見てきました。
空間分布の時間変化を示したい場合には時間1次元・空間1次元の等値線図を描くこともできます。このような図をホフメラー図と呼びます。
ここではある緯度を選び、横軸に経度、縦軸に時間を取ったホフメラー図を描いてみましょう。

まずは、x座標(経度)・y座標(時間)の値が入った2次元配列を作ります。簡易的にデータを見る場合には937944などが入った配列timeを使っても良いですが、ここではdatetimeを使って具体的な年を見ることにしましょう。

In [52]:
# Lon, Time = np.meshgrid(lon, time)
Lon, Time = np.meshgrid(lon, time * timedelta(hours=1) + datetime(1900, 1, 1))

ホフメラー図を描く関数

緯度配列の番号を指定し、その緯度でのSSTの時間変化をホフメラー図で描いてくれる関数を定義します。

In [53]:
def drawhov_sst(n):
    plt.figure(figsize=(6,8)) # 新しいキャンバスを立ち上げる; 見やすくするため、ちょっと縦長にする
    plt.contourf(Lon, Time, sst[:,n,:], # 緯度nについて、色塗り等値線図を描く
                 levels=lev,                # 色分けはlevに従って行う
                 cmap="plasma"              # 色調に"plasma"を用いる
                ) 
    plt.colorbar() # カラーバーを図の右側に表示する
    plt.title("latitude="+str(lat[n])) # 緯度を図タイトルにする
    plt.show() # 図を表示する; 複数枚図を描くとき必要
    
drawhov_sst(80)
drawhov_sst(120)

北緯40度では白塗りの陸地を挟んで3つの海が見えますが、これらは黄海・日本海・太平洋です。
北緯30度は九州の南あたりで、東経130度あたりに黒潮が通っているのが見えます。

気候値からの偏差

上の図では、やはりそのままのSSTは夏に温かく冬に冷たいという季節変動が卓越しており、それ以外の変動は目立たなくなってしまいます。
そこで、気候値sst_climからの偏差のホフメラー図を描く関数を定義しましょう。

練習4-6

sst (時間180点, 緯度161点, 経度241点)から各月の気候値sst_clim (時間12点, 緯度161点, 経度241点)を引き算して、気候値からの偏差の配列ssta(時間12点, 緯度161点, 経度241点)を計算しなさい。

上と同様に、いろんなやり方があるので解答例を見る前に自分で考えてみましょう。

気候値からの偏差のホフメラー図を描く関数

先に、緯度番号nについてsstaのホフメラー図を描く関数を定義しておきます。

In [54]:
def drawhov_ssta(n):
    lev = np.linspace(-5,5,21)
        
    plt.figure(figsize=(6,8)) # 新しいキャンバスを立ち上げる; 見やすくするため、ちょっと縦長にする
    plt.contourf(Lon, Time, ssta[:,n,:], # 緯度nについて、色塗り等値線図を描く
                 levels=lev,             # 色分け0.5度ごとに行う
                 cmap="seismic"                 # 色調に"seismic"を用いる
                ) 
    plt.colorbar() # カラーバーを図の右側に表示する
    plt.title("latitude="+str(lat[n])) # 緯度を図タイトルにする
    plt.show() # 図を表示する; 複数枚図を描くとき必要

練習4-6の解答例

In [55]:
# 解答例1: 月の通し番号についてforループを回し、各月の気候値を引く
ssta = np.zeros((180, 161, 241)) # 先に入れ物を定義する

for n in range(180):
    ssta[n,:,:] = sst[n,:,:] - sst_clim[n%12,:,:] # 月の通し番号を12で割った余り -> 0, 1, 2, ..., 11, 0, 1, 2,...
In [56]:
# 解答例2: 1~12月についてforループを回し、それぞれの月の気候値を引く
ssta = np.zeros((180, 161, 241))

for n in range(12):
    ssta[n::12,:,:] = sst[n::12,:,:] - sst_clim[n,:,:] # nから始まり12とばしで終わりまで -> 0, 12, 24, ..., 1, 13, 25, ...
In [57]:
# 解答例3: reshapeで配列の時間軸を15x12の形状に折り畳み、ブロードキャスト(後述)を用いて引き算し、元の形状にもどす
ssta = sst.reshape((15, 12, 161, 241)) - sst_clim.reshape((1, 12, 161, 241))
ssta = ssta.reshape((180, 161, 241))

データの例 (1) 北緯32.5度

ここでは興味深いデータの見られた2つの緯度(北緯32.5度、北緯27.5度)の図を見てみましょう。

In [58]:
drawhov_ssta(110)

まず北緯32.5度は四国よりわずかに南に位置する緯度で、東経130度付近の白い陸地が九州です。
2018年の少し前から、東経135度あたりに強い負の温度偏差(冷水)が見られます。これは現在(2022年12月)も続く黒潮大蛇行の期間であり、蛇行の北側にある冷水渦を横切っているために見られているものです。

データの例 (2) 北緯27.5度

次はより南の北緯27.5度でのホフメラー図を見てみましょう。

In [59]:
drawhov_ssta(130)

真横の縞状模様に加え、左上がりの模様が見えるでしょうか。真横の線は全域が同時に暖かい・冷たいという状況を表しており、これはその月が特別暖かい・冷たいというイベントをとらえています。 一方、左上がりの線は温暖・寒冷のパターンが時間と共に西に伝わってゆくことを表しています。暖水・冷水を中心とした多数の渦が、約5cm/sの速度(ホフメラー図から確かめてみましょう、経度1度は約100km)で西向きに伝播している様子が見て取れます。
これは地球が球面であるために自転効果が緯度変化することから生じるロスビー波の性質です。

ホフメラー図はこのような「シグナルの伝播」といったパターンをとらえるのに非常に有用です。

ブロードキャスト

最後に、配列の便利な機能の一つであるブロードキャストを簡単に説明します。 ブロードキャストとは、同じ形ではない配列であっても、ある条件を満たしていればベクトル演算が行える機能です。

実例と解説

まずは一つ、例を見てみましょう。

In [60]:
a = np.arange(6).reshape((2,3))
b = np.array([0,10,20]).reshape((1,3))

print(a)
print(b)
[[0 1 2]
 [3 4 5]]
[[ 0 10 20]]

abは形が異なるので原理的にはベクトル演算はできませんが、ブロードキャストのおかげで次のようにベクトル演算が行えます。

In [61]:
print(a+b)
print(a*b)
[[ 0 11 22]
 [ 3 14 25]]
[[  0  10  40]
 [  0  40 100]]

何が起こっているかわかりますか。
形状(2,3)aと形状(1,3)bを計算しようとしたとき、bは第1次元に関して繰り返した(2,3)の配列、つまり次のようなものとして解釈されているのです。

In [62]:
print(np.array([[0,10,20], [0,10,20]]))
[[ 0 10 20]
 [ 0 10 20]]

ブロードキャストを行うには、基本的には次元数ndim(今の場合2)、要素数が1ではない次元の要素数が一致していればよいです。
そのため、(2,3)の配列に対しブロードキャストできるのは(2,3), (2,1), (1,3), (1,1)の4種類ということになります。

In [63]:
# (2,3)と(2,1)のブロードキャストの例
c = np.array([-5, 10]).reshape((2,1))

print(a)
print(c)
print(a+c)
[[0 1 2]
 [3 4 5]]
[[-5]
 [10]]
[[-5 -4 -3]
 [13 14 15]]

2次元グラフを描く

In [64]:
# 例:2次元のグラフを描く
x = np.linspace(-5, 5, 201).reshape((1, 201))
y = np.linspace(-5, 5, 201).reshape((201, 1))

z = np.sin(x) * np.exp(-0.5*(x**2 + y**2))

X, Y = np.meshgrid(x, y) # これはどっちにしても必要

plt.contourf(X, Y, z, 41)
plt.colorbar()
Out[64]:
<matplotlib.colorbar.Colorbar at 0x1c795116f70>
In [65]:
z = np.sin(x) * np.exp(y)

plt.contourf(X, Y, z, 41)
plt.colorbar()
Out[65]:
<matplotlib.colorbar.Colorbar at 0x1c796512e50>

練習4-7

上と同様にx, yのブロードキャストを用いて、次の関数の2次元グラフを描きなさい。

  1. $z=xy$
  2. $z=\frac{x^2}{3}+\frac{y^2}{6}$
  3. $z=\sin\left(\frac{x^2-y^2}{2}\right)$
In [ ]: