【Kaggle実装】生き物の声を聞き分けろ!音声検出で森の希少種を守る
データサイエンス事業部の赤井です。本記事では、入社時の研修で挑戦したKaggleコンペの実装について紹介します。深層学習の初心者を対象としています。
この記事は、前半と後半で分かれています。今回参加したコンペ「Rainforest Connection Species Audio Detection」[1]や背景について知りたい方は、前半の記事[2]をお読みください。前半では、扱うデータセットについてや今回採用した作戦について説明しているので、読んで頂けると理解度が上がると思います。
また、深層学習の流れを学びやすいように、全てのクラスや関数を一つのノートブック上でまとめています。記事内でもコードを載せますが、完全に実装したい場合はGithubにあるノートブック[3]をダウンロードしてください。
学習の全体像
以下が主な学習の流れになります。
1. 音声処理(audio_DA.ipynb)
a. リサンプリング(サンプリングレートを統一)
b. ノイズ調整(Gaussianノイズ、Pinkノイズ)
c. 他のデータ拡張
i. ピッチシフト
ⅱ. タイムシフト
d. メルスペクトログラム画像として保存
2. 画像処理 (train.ipynb)
a. Gaussianノイズ
b. 画像をランダムに反転させる
3. 学習:初歩的な最初のモデルを作る (train.ipynb)
a. モデルを取得する
b. トレーニングデータで学習
c. テストデータで評価
d. 予測結果をcsvファイルを出力する。
途中で何の実装をしているのか分からなくなったときは、ここに戻って工程を確認しましょう。EfficientnetやGaussianノイズなどの用語は、これから順に説明していくので、全て理解している必要はありません。
開発環境を整える
Google Colab上でプログラムを書こう
ここでいう環境とは「森や海の環境」ではなく、どのようなソフトウェアやモジュールを使うのかということです!ここでは、その「環境」をどう構築するかについて説明します。
今回は音声処理をローカルのJupyter Notebookで行い、画像処理と学習はGoogle Colab Pro*で行います。ローカルとは、自分が使用しているパソコンのCPUやGPUを使ってコードを走らせるということです。ColabはGoogleが所有しているサーバーセンターのCPUやGPUを使って走らせるサービスのことです。
音声前処理だけをローカルで行ったのは、Kaggleから直接Google Driveにデータを保存したところファイルが欠損してしまったからです。なぜこのような問題が起きるのかは不明ですが、本来は起こらない問題なのでご自身で実装する際は、Colabでもローカルでもどちらでも構いません。KaggleからコマンドでDriveに保存できたらコメントでお知らせください。
音声処理を始める前にやらなければならないこと
①データをローカルに保存する
Kaggleのデータのダウンロードの仕方はこちら[4]をご覧ください。
② Jupyter Notebookをインストールし、Python3.xx以降のものをインストールしてください。そのほか「joblib, h5py, pandas, numpy, librosa, matplotlib, multiprocessing, pathlib, tqdm, torch, torchaudio」を使います。事前にpipやcondaでインストールしてください。
これで音声処理をする準備が整いました。使っているPCやmacによって環境構築やモジュールの互換性も変わるので、うまくいかないときは、一つ一つ調べながら進んでいきましょう。
音声処理
データ拡張をして過学習を防ごう
深層学習の欠点として、少ないデータで学習してしまうと少ないデータを基に予測してしまうため、予測結果に偏りが生じてしまいます。この現象を「過学習」と呼びます。過学習の状態だと、学習のために予め与えられた「学習データ(train data)」に対しての予測精度は高い一方で、未知の「テストデータ(test data)」に対しての予測精度が低くなってしまいます。
そこで、過学習を避けるために行うのが「データ拡張」です。 限られた学習データを基にさまざまなデータを生成し、データの量を増やすことで未知のデータに対してもある程度正しく予測ができるようになります。
では早速、データ拡張を行っていきましょう。Jupyter notebookで新しいノートブックをaudio_DA.ipynbとして作成します。もしくはGithubのaudio_DA.ipynbのノートブックを参考に実装してみてください。audio_DAとは、Audio Data augmentationのことで、音声のデータ拡張のことです。
最初に行うのは、モジュールのインポートの宣言です。モジュールとは、部分的に機能を集めた、いわば「工具を収納する箱」のようなものです。その箱から、さまざまなとんかちや、ノコギリといった道具や技を呼び出すようなイメージです。1行目では”numpy”というモジュールを、”np”という名前をつけてインポートしています。
#インポート
import numpy as np
import pandas as pd
import librosa as lb
import librosa.display
import matplotlib.pyplot as plt
import multiprocessing as mm
from IPython.display import Audio
import glob
import os
import joblib
import h5py
from tqdm import tqdm
from IPython.display import Audio
import librosa.display
今回はFacebookが開発したPyTorchと言われる深層学習用のライブラリを使用します。PyTorch以外にもGoogleが開発したTensorFlowやKerasなどのライブラリがあります。
ここからは、音声をどのようにノートブック上で聞いたり、波形の画像に変換したりできるのか見ていきます。保存されているflacファイルを読み込むために、パスをglob.globで取得します。パスとはファイルの住所のようなもので、どこにファイルが保存されているかをコンピュータに教えるものです。
flacfiles = glob.glob("train/*.flac")
waveform, sr = lb.load(flacfiles[0])
”train/*.flac”と引数にありますが、これはtrainフォルダーに入っている全てのflacファイルのパスを指定していることになります。
そして音声ファイルを読み込むために、librosaと呼ばれるモジュールlb.loadで、最初の音声ファイルをwaveformとsrに格納します。(librosaは音声を専門的に処理するモジュールです。flacfiles[0]は、flacfilesの0番目のデータを指します。)これによりwaveformがnp.ndarrayタイプの波形データになり、srがintタイプのサンプル数になります。
プログラミングを行う際に最も重要なことの一つとして、データタイプやList・Arrayのサイズを意識することがあります。データタイプやサイズが間違っていると、引数として認識されずエラーを起こす場合があるからです。そのため、必ず新しいモジュールを使うときには、公式のモジュールのドキュメントで引数のデータ型を調べてtype()や.shapeなどで確認してみてください。
このwaveform, srを音声として聞きたい場合は、IPython.displayでインポートしたAudio()を使います。波形を見たい場合はlibrosa.display.waveplotを使います。
Audio(waveform, rate=sr)
lb.display.waveplot(y, sr=sr);
メルスペクトログラムを表示するにはlb.feature.melspectrogramを使います。メルスペクトログラムについて知りたい方は前半記事をご覧ください。
melspec = lb.power_to_db(lb.feature.melspectrogram(waveform, sr=32000, n_mels=128, fmin = 0, fmax = 9000))
lb.display.specshow(melspec, sr=sr, x_axis="time", y_axis="mel")
plt.colorbar();
これで、音声データを可視化できるようになりました。
ここからは、以下の音声データの処理方法について説明していきます。
・Resampling
・GaussianNoise
・PinkNoise
・PitchShift
・TimeShift
・VolumeControl
今回はコンペ用に関数を作るのではなく一般的な音声データの拡張にも活用できるよう、上記の処理をそれぞれクラスとして作成します。それぞれの処理については順番に説明していきますので、今は名前だけでも覚えてください。
親クラスの実装
最初に親クラスとして指定した確率から、データごとに処理をするかしないかを決めるTransformWaveformクラスを作成します。ここでは、全ての音声に同じ処理をせず、ランダムにさまざまな特徴のデータを作成したいからです。下のコードを理解するにはこちらの記事[5]を見てから、親クラスと子クラスの関係やクラスの作り方について覚えてください。
# 確率的に処理を施すクラス
class TransformWaveform:
def __init__(self, always_apply=False, p=0.5):
self.always_apply = always_apply
self.p = p
def __call__(self, y: np.ndarray):
if self.always_apply:
return self.apply(y)
else:
if np.random.rand() < self.p:
return self.apply(y)
else:
return y
def apply(self, y: np.ndarray):
raise NotImplementedError
処理が行われる確率のデフォルト設定は50%で設定されており、ランダムに全体の半分の音声データに処理が施されます。常に処理を行うためにはalways_applyをTrueに設定します。
次に下の関数は複数の処理のリストを引数として入力し、一つの関数にするためのクラスです。一つにまとめることにより、様々な音声処理を抜いたり加えたりカスタマイズしやすくなります。
# 複数の処理を一つの関数のようにするクラス
class Multiple:
def __init__(self, transforms: list):
self.transforms = transforms
def __call__(self, y: np.ndarray):
for trns in self.transforms:
y = trns(y)
return y
Resamplingは音声ファイルのサンプル数を統一する処理
Resamplingはメモリの省力化や精度の向上のために、音声ファイルのサンプル数を統一するための処理のことです。そのためにlibrosa.resampleと言われるモジュールを使います。サンプル数とは、一秒間に音声データが何回刻まれているかということです。サンプル数が大きければ大きいほど、音のデータが細かく連続的に記録されているということです。
class ResampleWaveform(TransformWaveform):
def __init__(self, always_apply=True, p=0.5, sr):
super().__init__(always_apply, p)
self.sr = sr
def apply(self, y: np.ndarray, **params):
y_resampled = lb.resample(y, self.sr, 32000)
return y_resampled
上のスクリプトでは親クラスのTransformWaveformから確率的に処理を行うかどうかの引数を引き継ぐために、 super().__init__(always_apply, p)と書かれています。
そして処理前のサンプルレートはsrの変数に格納し、新たなサンプルレートを32000とします。こちらは32kHzという意味で、mp3やAMRadioのサンプリングレートよりは高く、flacなどの高音質のサンプリングレートよりは低くなります。
ただし、今回のデータのサンプリングレートはすでに統一されているため今回の場合は使用しても効果は生まれません。今後複数のデータセットを使う場合や、音声のサンプリングレートがそもそも統一されていない場合は、このクラスを使ってみてください。参考にしたのは、 Pytorchの音声に関する公式チュートリアル[6]です。
GaussianNoiseは取り得る値が正規分布に従うノイズ
Gaussian Noiseは取り得る値が正規分布に従うノイズで、別名ホワイトノイズやガウスノイズと言われています。つまり、全ての周波数帯に満遍なくノイズを加えるものです。今回は元のデータセットの音量がデータによって変わってくるため、元の信号の音量を基準に適切な音量のノイズを加えられるよう、元の信号の大きさとノイズの大きさの比率を固定します。この手法に関しては、Line Developerのこちらの記事[7]に詳しく書いてあります。
class GaussianNoiseSNR(TransformWaveform):
def __init__(self, always_apply=True, p=0.5, min_snr=5.0, max_snr=20.0, **kwargs):
super().__init__(always_apply, p)
self.min_snr = min_snr
self.max_snr = max_snr
def apply(self, y: np.ndarray, **params):
snr = np.random.uniform(self.min_snr, self.max_snr)
a_signal = np.sqrt(y ** 2).max()
a_noise = a_signal / (10 ** (snr / 20))
white_noise = np.random.randn(len(y))
a_white = np.sqrt(white_noise ** 2).max()
augmented = (y + white_noise * 1 / a_white * a_noise).astype(y.dtype)
return augmented
PinkNoiseは徐々にノイズの強さが大きくなるノイズ
Pink Noiseは高周波数帯から低周波数帯にかけて徐々にノイズの強さが大きくなるノイズです。自然界に存在するノイズはこのようなノイズだと言われています。
そのほかにもブラウンノイズや、ブルーノイズ等、様々な周波数帯にノイズが付け加えられるパターンがあり、自然のノイズにどう近づけられるか研究が行われています。こういったノイズをカラードノイズと呼んでおり、それらを生成するモジュールも存在します。今回はcolorednoiseを使い、元の信号の振り幅をもとにピンクノイズを発生させます。
import colorednoise as cn
class PinkNoiseSNR(TransformWaveform):
def __init__(self, always_apply=False, p=0.5, min_snr=5.0, max_snr=20.0, **kwargs):
super().__init__(always_apply, p)
self.min_snr = min_snr
self.max_snr = max_snr
def apply(self, y: np.ndarray, **params):
snr = np.random.uniform(self.min_snr, self.max_snr)
a_signal = np.sqrt(y ** 2).max()
a_noise = a_signal / (10 ** (snr / 20))
pink_noise = cn.powerlaw_psd_gaussian(1, len(y))
a_pink = np.sqrt(pink_noise ** 2).max()
augmented = (y + pink_noise * 1 / a_pink * a_noise).astype(y.dtype)
return augmented
PitchShiftは音の周波数を上げ下げする処理
Pitchとは音の高低差のことでShiftは周波数帯を上げ下げするということです。ただし、このデータ拡張は上げ下げのコントロールが激しすぎると、音割れが起きたり、そもそもの特徴を捉えられなくなる可能性があるので、max_stepsを慎重にコントロールしながらご自身でご確認ください。max_stepsは上げ下げの上限を決めるもので、その上限のなかで、ランダムにピッチが変わっていきます。
class PitchShift(TransformWaveform):
def __init__(self, always_apply=False, p=0.5, max_steps=5, sr=32000):
super().__init__(always_apply, p)
self.max_steps = max_steps
self.sr = sr
def apply(self, y: np.ndarray, **params):
n_steps = np.random.randint(-self.max_steps, self.max_steps)
augmented = librosa.effects.pitch_shift(y, sr=self.sr, n_steps=n_steps)
return augmented
TimeShiftは時間をずらす処理
TimeShiftは時間をずらす方法です。ずらし方は、前の部分に何も音のないデータを付け加え後ろの部分をカットするか、前の部分にカットした後ろの部分を付け加えるかの二通りあります。ここでは後者を採択しデフォルトとして設定します。前者の方法を採択したい場合はpadding_modeをzeroにしてください。
class TimeShift(TransformWaveform):
def __init__(self, always_apply=False, p=0.5, max_shift_second=2, sr=32000, padding_mode="replace"):
super().__init__(always_apply, p)
assert padding_mode in ["replace", "zero"], "`padding_mode` must be either 'replace' or 'zero'"
self.max_shift_second = max_shift_second
self.sr = sr
self.padding_mode = padding_mode
def apply(self, y: np.ndarray, **params):
shift = np.random.randint(-self.sr * self.max_shift_second, self.sr * self.max_shift_second)
augmented = np.roll(y, shift)
if self.padding_mode == "zero":
if shift > 0:
augmented[:shift] = 0
else:
augmented[shift:] = 0
return augmented
VolumeShiftは音量を調節する処理
VolumeControlは音量を調節する方法です。この方法がデータ拡張の中で一般的な方法です。音量を一様に減らす場合もありますが、時間によって音量を上げ下げする方法もあります。例えば、sine曲線やcosine曲線に合わせて音量を経過時間によって変化させることができます。なぜこのような方法をとるかというと、経過時間によって音量を変化させることによって、大きな変化をもたらし様々な特徴を捉えやすくなるからです。音量を全ての経過時間において一様に変化させたとしても、メルスペクトログラムには大きな変化が起きません。
class VolumeShift(TransformWaveform):
def __init__(self, always_apply=False, p=0.5, db_limit=10, mode="cosine"):
super().__init__(always_apply, p)
assert mode in ["uniform", "fade", "fade", "cosine", "sine"], \
"`mode` must be one of 'uniform', 'fade', 'cosine', 'sine'"
self.db_limit= db_limit
self.mode = mode
def apply(self, y: np.ndarray, **params):
db = np.random.uniform(-self.db_limit, self.db_limit)
if self.mode == "uniform":
db_translated = 10 ** (db / 20)
elif self.mode == "fade":
lin = np.arange(len(y))[::-1] / (len(y) - 1)
db_translated = 10 ** (db * lin / 20)
elif self.mode == "cosine":
cosine = np.cos(np.arange(len(y)) / len(y) * np.pi * 2)
db_translated = 10 ** (db * cosine / 20)
else:
sine = np.sin(np.arange(len(y)) / len(y) * np.pi * 2)
db_translated = 10 ** (db * sine / 20)
augmented = y * db_translated
return augmented
これで音声処理の実装は終わりました!ぜひご自身でパラメータを調整しながら、様々な特徴を作ってみてください。ほかにも音声のデータ拡張の方法があるので、Kaggleに上がっていたDiscussion[8]を見てください!
メルスペクトログラム
変換の実装ここでは音声処理前のデータと音声処理後のデータの全ての画像をメルスペクトログラムに変換し、データ量を2倍にします。さらにその全てのファイルをnpyに保存します。またコードを走らせる前に、以下の階層のようにフォルダーを作ってください。
ProjectFolder
├ train # flac files
├ test # flac files
├ audio_DA.ipynb
└ npy_files
└ train
└ test
# 使うCPUの数と、npyファイルの出力先
num_CPU = mm.cpu_count() - 1
new_dir = 'npy_files'
output_train= 'npy_files/train'
output_test= 'npy_files/test'
以下のパラメータは、lb.feature.melspectrogramの引数になります。
・sr=サンプリングレート
・n_mels=メルフィルタバンクの数
・fmin, fmax=メルフィルタバンクにおける周波数の下限と上限
# メルスペクトログラムのパラメータ
class melparams:
sr = 32000
n_mels = 128
fmin = 0
fmax = 10000
# 複数の音声処理を一つの関数にする
transform = Multiple([
GaussianNoiseSNR(min_snr=15, max_snr=30),
PinkNoiseSNR(min_snr=8, max_snr=30),
PitchShift(max_steps=2, sr=sr),
TimeShift(sr=sr),
VolumeShift(mode="cosine")
])
# メルスペクトログラム変換の関数
def compute_melspec(y, melparams):
melspec = lb.power_to_db(lb.feature.melspectrogram(y, *melparams), 2).astype(np.float32)
return melspec
ここではファイル名の最初の文字列に、処理前のデータに”0”をつけ処理後は”1”をつけて区別することにしました。また、メルスペクトログラムに変換するのは処理前のデータと処理後のデータの両方ですので、上書きしないようご注意ください。
# 処理前のデータと処理後のデータの変換と保存するための関数
def load_and_save(record, out_dir):
y, _ = lb.load(record)
y_augmented = transform(y)
melspec = compute_melspec(y, melparams)
melspec_augmented = compute_melspec(y_augmented, melparams)
record_name = '0' + record.split('/')[-1].replace('.flac', '.npy')
augmented_record_name = '1' + record_name.replace('.flac', '.npy')
np.save(f'{out_dir}/{record_name}', melspec)
np.save(f'{out_dir}/{augmented_record_name}', melspec_augmented)
これで変換と保存する準備は終わりました。元のファイルのパスを取得しましょう!複数のファイルのパスを取得するには、glob.globと言われるモジュールを使います。このモジュールは複数のパスをリストにしてくれるのでとても便利です。このモジュールには、ほかにも名前や拡張子によってフィルターする機能も備わっているので、公式のドキュメント[9]をみて実装してみると、今後役に立つかもしれません。
train_files = glob.glob('train/*.flac')
test_files = glob.glob('test/*.flac')
train_files.sort()
test_files.sort()
次に行うのは変換と保存です。これらの作業は複数のCPUで行いたいので、joblib.Parallelを使って時間を短縮します。num_CPUとは、使うCPUの数のことで、使用しているコンピュータのCPUの数より一つ少ないCPU数で処理させています。
# 音声toメルスペクトログラム変換とnpy files保存
_ = joblib.Parallel(n_jobs=num_CPU)(
joblib.delayed(load_and_save)(i,j) for i,j in tqdm(zip(train_files, [output_train]*len(train_files)), total=len(train_files))
)
_ = joblib.Parallel(n_jobs=num_CPU)(
joblib.delayed(load_and_save)(i,j) for i,j in tqdm(zip(test_files, [output_test]*len(test_files)), total=len(test_files))
)
次に、変換した複数のnpyファイルを正規化し一つのファイルに圧縮します。こうすることで、データセットを他の人に共有したり、またオンライン上にアップロードしやすくなります。
まず正規化するための関数を作ります。ここで重要なのは、全てのデータのmean(平均値)とstd(標準偏差)を引数として入力することです。これらの値はファイルを取得してから、mean()とstd()の関数を使って導き出します。
def normalize(X, eps=1e-6, mean=None, std=None):
mean = mean or X.mean()
std = std or X.std()
X = (X - mean) / (std + eps)
_min, _max = X.min(), X.max()
if (_max - _min) > eps:
V = np.clip(X, _min, _max)
V = 255 * (V - _min) / (_max - _min)
V = V.astype(np.uint8)
else:
V = np.zeros_like(X, dtype=np.uint8)
return V
次にHDF5ファイルの作成の仕方を覚えていきます。このファイルはよく使われているcsvファイルよりもより読み書きが早く、さらにその読み書きがフォルダシステムに感覚が近く、データの整理がしやすくなっています。python以外にもR、Java、Goでも使えます。h5pyはそのファイルを作成するためのモジュールです。
train_files = glob.glob(f'{new_dir}/train/*')
test_files = glob.glob(f'{new_dir}/test/*')
with h5py.File(f'{new_dir}.hdf5', mode='w') as f:
train_files = glob.glob(f'{new_dir}/train/*')
test_files = glob.glob(f'{new_dir}/test/*')
mean = []
std = []
for i in tqdm(train_files + test_files):
file = np.load(i)
mean.append(file.mean())
std.append(file.std())
mean = np.array(mean).mean()
std = np.array(std).mean()
base = np.load(train_files[1])
print(base.shape)
shape = (len(train_files), *base.shape)
print(shape)
f.create_dataset('train_files', (len(train_files), *base.shape), np.uint8)
f.create_dataset('test_files', (len(test_files), *base.shape), np.uint8)
dt = h5py.special_dtype(vlen=str)
f.create_dataset('train_labels', (len(train_files),), 'S10')
f.create_dataset('test_labels', (len(test_files),), 'S10')
f['train_labels'][...] = [i.split('/')[-1].split('.')[0].encode("ascii", "ignore") for i in train_files]
f['test_labels'][...] = [i.split('/')[-1].split('.')[0].encode("ascii", "ignore") for i in test_files]
for i, v in tqdm(enumerate(train_files), total=len(train_files)):
f['train_files'][i, ...] = normalize(np.load(v), mean=mean, std=std)
for i, v in tqdm(enumerate(test_files), total=len(test_files)):
f['test_files'][i, ...] = normalize(np.load(v), mean=mean, std=std)
今回はデータ属性を加えより扱いやすくしました。作成したHDF5ファイルはGoogle driveにアップロードしてみてください。多少複雑かもしれませんが、こちらの記事[10]]を参考にすると理解が深まると思います。
カスタムデータローダーの実装
データとラベルを紐付けるクラスの作り方
Colab上にnpy_files.h5pyをアップロードしたら、同じフォルダー内にKaggleのデータセットに入っているtrain_tp.csv、train_fp.csv、sample_submission.csvをアップロードし、train.ipynbノートブックをColabで作成してください。フォルダーの階層イメージは下記の通りです。
ProjectFolder
├ train.ipynb
├ npy_files.h5py
├ train_tp.csv
├ train_fp.csv
└ sample_submission.csv
次にGoogle DriveとColabを繋げなければなりません。そのためには以下のコードを実行すると、リンクが表示されパスワードが発行されるので、それをcolabに入力してください。
import os
from google.colab import files, drive
drive.mount('/content/drive')
DRIVE_PATH = '/content/drive/MyDrive/RCSAD'
os.chdir(f'{DRIVE_PATH}')
次にefficientnetとresnestをcolab上のセルでインストールしてください。
これらは後ほど説明する画像認識用のモデルのために使うものです。
!pip install transformers efficientnet_pytorch &> /dev/null
!pip install git+https://github.com/zhanghang1989/ResNeSt
そしてtrain.ipynbでのインポートは以下のように入力してください。
# インポート
import random
import math
import time
import datetime
import h5py
import multiprocessing
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from tqdm import tqdm
from sklearn.model_selection import GroupKFold
import torch
import torchvision.transforms as transforms
import torch.nn as nn
from torch.optim import Adam
from torch.utils.data import DataLoader, Dataset
from transformers import get_linear_schedule_with_warmup
import resnest.torch as resnest_torch
from efficientnet_pytorch import EfficientNet
ここの設定値で特筆すべき値は、num_classesです。これはいくつのグループに分類するのかクラスの数を設定するものです。今回の場合は、24種類の生き物がいるので、num_classes=24となります。
また、one_hotとありますが、これは、(1,0,...0,0),(0,1,...0,0)...(0,0,...1,0),(0,0,...0,0)のように多次元のカテゴリーを全て同等に扱うためにあるもので、数値ではないカテゴリーをデータとして処理するためにこういったベクトルに変換します。
# 設定値
npy_files = 'npy_files'
num_classes = 24
one_hot = np.eye(num_classes)
cut = 6
sample_time = 5
image_height, image_width = 300, 300
num_cpu = multiprocessing.cpu_count()
audio_length = 60
次にhdf5からデータを読み込み、ファイル名からデータを呼び出せるようなdictionaryを作ります。また、正解ラベルが入っているtrain_tpをdf_tpとして、予測しなければならないデータ情報をもっているsample_submission.csvをdf_testとします。
def load_dataset(label, h5file):
dataset = h5py.File(f'{h5file}.hdf5', 'r')
recording_ids = [i.decode('utf-8') for i in dataset[f'{label}_labels']]
data = {k:v for k, v in tqdm(zip(recording_ids, dataset[f'{label}_files']), total=len(recording_ids))}
return data
train_data = load_dataset('train', npy_files)
test_data = load_dataset('test', npy_files)
df_tp = pd.read_csv(f'train_tp.csv')
df_test = pd.read_csv(f'sample_submission.csv')
次にdf_tpには、鳴き声が鳴っている時間の情報(t_maxとt_min)があるので、その情報から音声を切り取る関数を作ります。そして、60秒間の長さで統一しなければならないので、ランダムにその前後を含ませるようにします。この関数をtrain用とtest用に2つ作ります。train用とtest用の関数には違いがあり、df_testには実際の鳴き声が鳴っている時間t_minとt_maxの情報が存在しません。そのため、音声をまず5秒間ごとに分け、それを元に擬似的にt_minとt_maxをtest_df関数で作ります。その情報から、また同じようにcut_test関数で画像を切り取ります。
def cut_train(x):
full = x['audio_spec'].shape[1]
adj = full / audio_length
center = (x['t_max'] + x['t_min']) / 2
tmax = center + cut / 2
tmin = center - cut / 2
extra_min = max(0, tmax - audio_length)
extra_max = -min(0, tmin)
start_cut = max(0, tmin) - extra_min
end_cut = min(audio_length, tmax) + extra_max
half_time = (cut - sample_time) / 2
extra = np.random.uniform(-half_time, half_time)
start_cut += extra + half_time
end_cut += extra - half_time
start_cut = int(start_cut * adj)
end_cut = int(end_cut * adj)
x['audio_spec'] = x['audio_spec'][:, start_cut: end_cut]
return x
def test_df(dfx):
def summary_row(row):
stride = 1
cuts = np.vstack([[i,i+sample_time] for i in range(0, audio_length-stride, stride)])
row_new = pd.DataFrame(data={'recording_id': row.iloc[0, 0],
't_min': cuts[:, 0],
't_max': cuts[:, 1]
})
return row_new
df_new = dfx.groupby(['recording_id'], as_index=False).apply(summary_row).reset_index(drop=True)
return df_new
def cut_test(x):
full = x['audio_spec'].shape[1]
adj = full / audio_length
tmax = x['t_max']
tmin = x['t_min']
half_length = sample_time / 2
center = (tmax + tmin) / 2
extra_min = max(0, center + half_length - audio_length)
extra_max = -min(0, center - half_length)
start_cut = max(0, center - half_length) - extra_min
end_cut = min(audio_length, center + half_length) + extra_max
start_cut = int(start_cut * adj)
end_cut = int(end_cut * adj)
x['audio_spec'] = x['audio_spec'][:, start_cut:end_cut]
return x
最後に、画像データ(train_data)とラベル情報(df_tp)が紐付けされていないので、画像データとラベルを一緒に引き出しやすくするために、カスタムデータローダーのクラスを作ります。このクラスは親クラスtorch.utils.dataのDatasetのクラスを引き継いでいます。これにより前処理(transform)がしやすくなります。ここで重要なのは、df_tpのrecoding_idから、train_dataを呼び出し、新たな複数の情報(audio_spec,recoding_id,species_id,t_min,t_max,target)を持つdictionaryを作っているということです。test用のデータセットもほぼ構造は同じなので省略します。
class AudioDataset(Dataset):
def __init__(self, df, transform=None, data=train_data, train=True):
self.train = train
self.data = data
self.files = df['recording_id'].values
self.df = df
self.transform = transform
self.y = [one_hot[int(i)] for i in df['species_id'].values]
def __len__(self):
return len(self.files)
def __getitem__(self, idx: int):
new_files = np.array(['0' + self.files[i] for i in range(len(self.files))] +
['1' + self.files[i] for i in range(len(self.files))]
).astype('object')
recording_id = new_files[idx]
data = self.df.iloc[idx, :]
X = self.data[recording_id]
species_id = data['species_id']
output = {
'audio_spec': X,
'recording_id': recording_id,
'species_id': species_id,
't_min': data['t_min'],
't_max': data['t_max'],
'target': self.y[idx],
}
if self.train:
output = cut_train(output)
else:
output = cut_test(output)
if self.transform is not None:
image = self.transform(output['audio_spec'])
image = image.numpy()
output['audio_spec'] = image
return output
画像処理
画像でのデータ拡張
音声拡張でもホワイトノイズを加えましたが、画像でも似たようなノイズを加えることができます。その方法はガウス分布に基づく値を元にデータを足すことです。これはこちらの記事[11]を参考に実装しました。
class GaussianNoise(object):
def __init__(self, mean=0.5, std=0.2, sigma=0.3, prob=0.8):
self.std = std
self.mean = mean
self.sigma = sigma
self.prob = prob
def __call__(self, tensor):
if torch.rand(1) < self.prob:
sample_noise = torch.randn(tensor.shape) * self.std + self.mean
sample_noise *= self.sigma
tensor = tensor + sample_noise.to('cpu').detach().numpy().copy()
return np.uint8(tensor)
def __repr__(self):
return self.__class__.__name__ + '(mean={0}, std={1})'.format(self.mean, self.std)
画像反転のクラスはこちらです。こちらはランダムに確率によって上下に反転をするものです。
class FlipImage(object):
def __init__(self, rateV=0.33):
self.rateV = rateV
def __call__(self, tensor):
if np.random.rand() < self.rateV:
tensor = tensor[::-1]
return tensor
def __repr__(self):
return self.__class__.__name__
正直にいうとデータ拡張するのにあたって、メルスペクトログラムの画像を反転させてみましたがモデルの精度はあまり変わりませんでした。生成したデータは自然の音の情報とは全く異なっているからです。実際、上下左右にランダムに反転させると音が逆再生みたいになったり、音が反転したデータの特徴になってしまったりするからです。これらは自然に録音された音のデータとは特徴が全く違います。一方、ホワイトノイズやピンクノイズであれば、実際の音声データも録音の都合上常にノイズは存在するもので、それを再現する意味でもノイズでデータ拡張するのはとても良い作戦でした。
最後にメルスペクトログラムのRGBチャンネルを増やすMonoToColorのクラスですが、先ほどのlibrosaで表示させた画像はもうすでにカラー写真なのではないかと疑問を持つ方もいらっしゃると思います。
しかしそもそもlibrosaのメルスペクトログラムは見やすくするために、色を後付けしたものなので、実際のデータは白黒のデータでチャンネルは1つしかありません。そのため、RGB値の3つのチャンネルに値を与え、カラー画像に変換する必要があります。これはデータ拡張ではなく、データ全てに反映させるものです。これによりデータをtorchモジュールのToPILImage()を使ってPythonで扱いやすい画像形式への変換が可能になります。
class MonoToColor(object):
def __init__(self, eps=1e-6, mean=None, std=None):
self.mean = mean
self.std = std
self.eps = eps
def __call__(self, X):
mean = self.mean or X.mean()
std = self.std or X.std()
X = (X - mean) / (std + self.eps)
_min, _max = X.min(), X.max()
if (_max - _min) > self.eps:
V = np.clip(X, _min, _max)
V = 255 * (V - _min) / (_max - _min)
V = V.astype(np.uint8)
else:
V = np.zeros_like(X, dtype=np.uint8)
V = np.stack([V, V, V], axis=-1)
V = V.astype(np.uint8)
return V
def __repr__(self):
return self.__class__.__name__
モデリング
画像認識モデルとしてEfficientNetとResNeStを使用
今回使用するモデルは、EfficientNet(2019)[12]とResNeSt(2020)[13]で主に画像認識に使われるモデルです。
EfficientNetはレイヤーの深さだけでなく、広さや入力画像の大きさを定数倍することでスケールアップする
Efficientnetモデルは2019年5月にGoogle Brainから発表されたCNNをベースとしたモデルで、従来よりパフォーマンスが飛躍的に進化したモデルになります。少ないパラメータでより高い精度で学習することが可能なモデルです。
ちなみにCNNとは畳み込みネットワークのことで、レイヤを進むごとに画像を小さくするフィルターをかけ、移動や変形にも耐えられるよう様々な特徴を捉えられるようになるようなネットワークです。ただCNNは画像の解像度に依存するので、全てのデータの解像度を一定にしなければなりません。そのため、物体の縮小、拡大の特徴が捉えにくいのが唯一の難点です。
EfficientNetが画期的なのはそのモデルのスケールアップ方法です。下図はスケールアップのイメージです。従来のResNetでは、ただ深層学習の層の数を増やすことによって、モデルの精度を上げていました。EfficientNetではスケールアップする方法を少し変え、レイヤーの数(深さ)だけでなく、レイヤーの広さ、そして入力する画像の大きさを全て定数倍します。そのため、CNNの構造自体には手を加えなくて済みます。
[12]EfficientNet: Rethinking Model Scaling for Convolutional Neural Networks
ResNeStモデルは2020年12月にFacebook、UC Davis、Snap、Amazon、ByteDance、SenseTimeが共同で発表したResNetをベースにしたモデルです。まずResnetで特徴的なのはBottleneck Blockと言われる基本構造で、下の図のように、入力された特徴をただ次のレイヤーに入力するだけではなく、スキップして例えば2つ後のレイヤーに出力するような構造になります。これは”Residual Network”とも呼ばれ、ResNetの名前の由来でもあります。二つの入力が結合され次の層に入力されることで、その違いが新たな特徴になり、最適化しやすくなるのが利点です。
[14]Deep Residual Learning for Image Recognition
ResNeStは3つ以上の特徴を捉えてその差分を参考にすることで精度を高めることができる
この構造をさらに複雑に発展させたのがResNeStで、基本構造は左下の図のようになっています。これは全体像ではなく、一つのブロックだと思ってください。ここで大事なのはSplit Attentionというレイヤーでそれを詳しく図にしたのが、右の図になります。
[13]ResNeSt: Split-Attention Networks: Figure2
この構造は多少複雑ですが、一言で言うとResNetのように2つだけでなく、3つ以上の特徴を捉えて、さらにその差分を参考に精度が高められるということです。
これら二つのモデルの精度を比べてみると以下のようなテーブル結果になりました。EfficientNet-b3の方がResNeSt50より精度が高いことがわかります。論文に掲載されているグラフを見ていただくと、実際にEfficientNet-b3はResNeSt50よりも精度(acc%)が高いことがわかります。しかし計算速度(avg latency)という軸を考えると、ResNeSt50の方が少ない計算量でより効率的に精度を出していることが分かります。そしてEfficientNet-b7とResNeSt269を比べるとよりその差が鮮明になり、明らかにResNeStが計算効率よく精度を叩き出しているのがわかります。
[13]ResNeSt: Split-Attention Networks: Figure1
このように精度のことだけを考えればよいケースでは、EfficientNetも十分検討可能です。ただ、計算速度を要求するケース、例えば自動運転の物体検知などでは、ResNeStがより有用です。
実際にはこれら2つのモデル以外にも日々、新しいモデルの研究が行われています。最近ではTransformerを使ったDETRやViTといった、CNNを使わない新しいモデルが作られていて、より計算量が軽く精度の高いモデルが求められています。
実装では次のようにEfficientNetとResNeStのモデルを取得します。取得後はEffcientnetとResNeStが出力したデータをさらにDropout、Batch Normalizationといった処理を行った後で、モデルに入力しなければなりません。これによりデータサイズを(1x24)まで圧縮することができます。最後に出力されたデータは、データがそれぞれどれくらいの確率でそれぞれのクラスに属しているのかを示すものです。例えば(0.01, 0.8,0.01,...,0.02, 0.05, 0.04)の出力の場合は0.8が一番大きいので、そのデータは2種類目のクラスに分類されます。
def obtain_model(name, num_classes=24):
if "resnest" in name:
model = getattr(resnest_torch, name)(pretrained=False)
model.load_state_dict(torch.load('resnest50_fast.pth'))
elif "efficientnet" in name:
model = EfficientNet.from_pretrained(name)
else:
raise NotImplementedError
if "efficientnet" not in name and "se" not in name:
nb_ft = model.fc.in_features
del model.fc
model.fc = nn.BatchNorm1d(nb_ft)
else:
nb_ft = model._fc.in_features
del model._fc
model._fc = nn.BatchNorm1d(nb_ft)
layers = 64
dropout = 0.3
modules = [
model,
nn.Dropout(dropout),
nn.Linear(nb_ft, layers),
nn.ReLU(),
nn.BatchNorm1d(layers),
nn.Dropout(dropout),
nn.Linear(layers, num_classes)
]
return nn.Sequential(*modules)
そのほか損失関数、重みをアップデートするAdam optimizer、学習を追うごとに学習率を下げるschedulerなどさまざまなモジュールが重要になってきますが、そのことについては次のセクションで詳しく説明します。
学習
最後に前のセクションで作ったモデルを学習させたいと思います。実際には学習の前にエポックごとに学習の精度やロス算出したり、それらのデータを元にmatplotlibでグラフを作成する関数や、学習させたモデルの重みファイルを保存する関数、その学習済みモデルにsample_submission.csvを入力し予測結果を出すための関数を作ります。(それらの作り方はGithubのコードを読んでいくと掴めると思うので省きます。)深層学習を実装する上で重要なのは、損失関数、オプティマイザー、スケジューラーと行ったモジュールの機能を覚えることです。ノートブック内では、fit()関数の部分を参照してください。
予測値と正解値の差が小さくなるように重みを調整する
深層学習(ディープラーニング)を簡単に言うと、多くのデータをもとに近似関数を作りその係数もしくは重みを調整することです。そのために、重みやバイアスを毎回変えて予測値を出し、正解値との差を出すのが損失関数(Loss Function)です。例えば以下の式で言うとwが重み、bがバイアスで、yが予測値になります。φは活性化関数(ReLuやSigmoid)と言われるもので真っ直ぐな線形データではなく、入り組んだ非線形データに対応するために使われるものです。
y = φ(wx + b)
今回のコンペの場合は、鳥やカエルの鳴き声をメルスペクトログラムに変換した画像を大量に学習させ、学習済みネットワークに変換します。このネットワークの一つ一つのシナプスに重みが付与されています。そして一回ごとの学習に予測値と正解値の誤差を表してくれます。この誤差つまり損失関数が最も小さくなる重みを探し、適正な重みを見つけるのがニューラルネットワークなのです。
また、損失関数にも種類があり、回帰問題に使われる平均二乗誤差、平均絶対値誤差、二値分類に使われるバイナリー交差エントロピーなど用途によって使い分ける必要があります。実装では、多値分類でラベルがたくさんあるのでロジットバイナリー交差エントロピーと言われるものを使います。
import torch.nn as nn
loss_fct = nn.BCEWithLogitsLoss(reduction="mean").cuda()
.cuda()はこの関数をGPU上で計算するためのものです。損失関数についてもう少し知りたい方はこちら[15]が便利です。
Optimizerは最も効率的に損失をゼロにするものを選ぶ
Optimizerは最適化アルゴリズムのことです。こちらは、先ほどの損失関数を小さくしてくれるアルゴリズムです。Optimizerに求められるのはいかに最も効率的に「損失」をゼロにしてくれるかということです。
最適化アルゴリズムには、SGDやモーメンタム、NAGなど様々なアルゴリズムがありますが、全て最急降下法の派生であるので、ここでは最急降下法のみお伝えします。最急降下法は、全てのデータから予測値を出し、正解値と予測値の損失関数の微分の勾配から重みやバイアスを調整するという方法で主に成り立っています。これを数式で表すと以下の通りです。
wtは更新n回目のパラメータで、α,∇w, L(w)は学習率(learning rate)、パラメータでの微分、損失関数です。この学習率とは毎学習ごとにどれだけ損失関数の微分結果を反映させるかを決めるものです。
最急降下法では全てのデータを使ってパラメータを一気に更新してしまいますが、計算量が大きすぎて時間がかかってしまったり、最小値が二つ以上ある場合最も小さい最小値を見つけられなくなったりします。これらの問題点を克服したのがSGD、モーメンタム、RMSProp、Adamです。今回使ったのはAdamです。こちらは一つのデータをランダムに選択し、移動平均を使って素早く最小値に届くようにし、さらに学習率を勾配によって変化させる手法で、今では最も使われているOptimizerの一つです。
実装するには以下のようにtorch.optimからAdamをインポートします。
from torch.optim import Adam
optimizer = Adam(model.parameters(), lr=lr)
他のSGD、モーメンタム、RMSPropといったOptimizerについて知りたい方はこちらのリンク[16]をご覧ください。
Schedulerは学習率を少しずつ下げていくための機能
スケジューラーとは学習が進むにつれて、学習率を徐々に下げていく機能です。これは学習率が高すぎると、過学習を起こしたり、最適解から外れてしまう可能性を排除するために使われています。スケジューラーにもさまざまな種類がありますが、今回はget_linear_schedule_with_warmupを使います。
from transformers import get_linear_schedule_with_warmup
scheduler = get_linear_schedule_with_warmup(
optimizer, num_warmup_steps, num_training_steps
)
ここでは、num_warmup_steps分は学習率をオプティマイザーのAdamに任せ、それ以降は訓練終了(num_training steps)までに0に線形で減衰するよう設定しています。この他にもtorch.optim.lr_schedulerを使えば、ExponentialLR, StepLRなど様々な形で学習率を下げることができます。それらのスケジューラーの性質についてはこちらのリンク[17]に情報があります。
これら三つの全てを完全に実装するには、以下のように行います。実際には学習ロス、検証ロス、学習スコア、検証スコアなどを出すための関数等を実装しなければなりません。詳しくはノートブックでご確認ください。
loss = loss_fct(y_pred, y.cuda().float())
loss.backward()
optimizer.step()
optimizer.zero_grad()
scheduler.step()
【学習の設定】
・Efficientnet-b3もしくは ResNeSt50
・5分割交差検証
・ミニバッチ学習:バッチサイズ32
・200エポック
・初期設定の学習率
上記のように設定するためにはまずtrain関数とk-fold関数を作ります。これらの関数はノートブックを参照してください。k-foldとはk分割交差検証のことで、Configではk=5とし5分割としました。交差検証とは学習データを分割しそのうち1つを検証用データとし、他のデータを学習用に使うという手法です。それを5回それぞれ違うデータで以下のように行います。
[18]交差検証の図
交差検証[18]する理由は学習データセットを全て学習に性能に使ってしまうと、その汎化性能が測定できないからです。そして学習する中でさらにミニバッチ学習[19]を行います。ミニバッチ学習とは、学習を一括して行うのではなく小さいまとまりのデータごと(今回は32)に行うことによって、メモリをセーブしながら大きなデータを学習することができます。
class Config:
seed = 2021
gpu = True
k = 5
selected_folds = [0,1,2,3,4]
name = "final"
#どちらかのモデルをコメントアウトして選んでください。
selected_model = 'efficientnet-b3'
#selected_model = "resnest50_fast_1s1x64d"
batch_size = 32 # 学習用バッチサイズ
val_bs = 32 # 検証用バッチサイズ
epochs = 200
lr = 1e-3
そして最後にこれらの設定Configとdf_tpと、train_tp.csvをk-fold関数に入力することによって、学習が始まります。
val_result, score = k_fold(config=Config, dfx=df_tp)
次にtest関数にConfigとdf_testを入力し、予測を始めます。
result = test(config=Config, dfx=df_test)
そしてkaggleにcsvファイルをアップロードするために、resultをcsvファイルとして出力します。名前はなんでもよいですが、毎回名前を変えるのが面倒なのでdatetime.date.today()で日付を呼び出して名前にしています。
today = str(datetime.date.today())
file_name = f"{today}_{Config.selected_model}_{Config.name}_{score:.4f}"
result.to_csv(f'submissions/{file_name}.csv', index=True)
ぜひkaggleに出力されたcsvファイルをアップロードして、精度を確認してみてください!ちなみに大体0.89を超えるとkaggleのメダル圏内に入ります!
改善点
ファインチューニングの必要性
今回のチュートリアルではFine-tuningを行いませんでした。FineTuningとは似たようなデータを事前に学習させそのモデルの重みを初期設定の重みとして新しいデータを学習させることです。
今回の場合は、似たような音声データのスペクトログラムを事前に学習させ、モデルの重みを保存し、本番のデータでそのモデルで学習させることにより、精度を向上できる可能性があります。具体的には、過去に行われた似ているデータセットがBirdCLEF2021[20]がKaggle上にあるのでそちらでFineTuningさせるとさらに改善できるかもしれません。
損失関数の再定義について
今回は使いませんでしたが、初歩的なアルゴリズムで種が存在すると予測されてはいたけれど専門家によって否定されたFalse Positiveのデータ(train_fp.csv)が存在します。こちらについて詳しく知りたい方は前半の記事[21]をご参照ください。そしてそのデータ量はTrue Positiveの1132件に対して約4倍の3958件もあります。「そこにその種が存在しない」という情報なので扱いが難しいですが、精度を上げる意味では大事な情報になります。
ヒントは、True Positiveラベルをtorch.onesで1とし、False Positiveラベルをtorch.zerosで変換し、nn.BCEWithLogitsでそれぞれのロスを計算してください。このアイデアはKaggle GrandMasterのCPMP氏のSolution[22]によるものです。
学習後のモデルで学習データの「鳴き声が存在するのにラベルがない部分」を検知してラベルを付け直す
今回のコンペで難しかったのは、与えられた学習データのラベルが完全ではないということです。前半の記事の通り鳴き声が存在するのに、ラベルが全くない箇所があります。そのため、その部分を検知するために今回学習したモデルを使うとより多くのデータで学習できるかもしれません。
今回のGithubに上げたNotebookではこれらの方策は実装はしませんでしたが、個人的にこれらの手法を試したところ飛躍的にスコアが高まりました。
参考文献
[1]Kaggleコンペページ
https://www.kaggle.com/c/rfcx-species-audio-detection/
[2]前半記事
https://note.com/estyle_blog/n/n277ee543b173
[3]今回実装する際に使用したコードがみれるGithubレポジトリ
https://github.com/ra9g16/RFCX
[4]Kaggleデータセットのダウンロードの仕方
https://qiita.com/k_ikasumipowder/items/1c20d8b68dbc94ab2633
[5]Pythonでのクラスの作り方
https://blog.codecamp.jp/python-class-2
[6]Pytorch公式チュートリアル
https://pytorch.org/tutorials/beginner/audio_preprocessing_tutorial.html
[7]元の信号からノイズの大きさを算出する方法
https://engineering.linecorp.com/ja/blog/voice-waveform-arbitrary-signal-to-noise-ratio-python/
[8]音声データ拡張について
https://www.kaggle.com/hidehisaarai1213/rfcx-audio-data-augmentation-japanese-english#%E9%9F%B3%E3%83%87%E3%83%BC%E3%82%BF%E3%81%AB%E5%AF%BE%E3%81%99%E3%82%8BData-Augmentation-(Data-Augmentation-for-waveform)
[9]glob公式ドキュメント
https://docs.python.org/ja/3/library/glob.html
[10]HDF5ファイルについて
https://qiita.com/simonritchie/items/23db8b4cb5c590924d95
[11]画像データ拡張について
https://debuggercafe.com/adding-noise-to-image-data-for-deep-learning-data-augmentation/
[12]EfficientNet論文【英語】
https://arxiv.org/pdf/1905.11946.pdf
[13]ResNeSt論文【英語】
https://arxiv.org/pdf/2004.08955.pdf
[14]ResNet論文【英語】
https://arxiv.org/pdf/1512.03385.pdf
[15]損失関数について
https://www.intellilink.co.jp/column/ai/2019/031400.aspx
[16]オプティマイザーについて
https://qiita.com/omiita/items/1735c1d048fe5f611f80
[17]スケジューラについて
https://wonderfuru.com/scheduler/
[18]K分割交差検証について
https://qiita.com/LicaOka/items/c6725aa8961df9332cc7
[19]ミニバッチ学習について
https://aizine.ai/glossary-mini-batch/https://aizine.ai/glossary-mini-batch/
[20]BIRD CLEF データセット
https://www.kaggle.com/c/birdclef-2021
[21]前半記事
https://note.com/estyle_blog/n/n277ee543b173
[22]偽陽性ラベルの扱い
https://www.kaggle.com/c/birdsong-recognition/discussion/183219
採用サイト
エスタイルは、「コウキシンが世界をカクシンする」という理念のもと、企業のDXを推進中です。経験・知識を問わず、さまざまな強みを持ったエンジニアが活躍しています。
「未経験文系からデータサイエンティストへ」
https://www.wantedly.com/companies/estyle/post_articles/299673
弊社では、スキルや経験よりも「データを使ってクライアントに貢献したい」「データ分析から社会を良くしていきたい」という、ご自身がお持ちのビジョンを重視しています。
ご応募・問い合わせはこちら。
この記事が気に入ったらサポートをしてみませんか?