卒論を支えた技術
目次
とりあえず学部の卒論が書けたので、ゴール前に寝そべりながらこういう文章を書いてる。
ここ半年ぐらい卒論という存在の生活に占める割合が大きすぎて、個人として技術に関して得た知見のアウトプットがあまり出来なかったので卒論で構築したWebアプリをネタにちまちま書く。
さらっと研究紹介
ある用途向けた音声のデータセット構築を行っていた。
データセットの構築は「構築して終わり」じゃなくて、構築したデータセットの性能や特性の評価とかも行うが、この過程に対してそこまで興味が沸かなかったので今回は書かない。手法調べてGoogle Colabでscikit-learn使ってバババッてしたらモデルの学習完了だったので、特に収穫と思しきものがなかったし楽しくもなかった。
全部完了して「よし卒論書くぞ」と始めたところ、結局データセットの仕様やら評価手法やらを中心に書くことになったので、全然データ収集の話を書けなくて萎えていた。論文だから仕方ないがせっかくなのでブログに吐き出して供養する。
研究を支えた技術
アプリの背景
はじめにアプリの背景について触れる。
データの収集にはご時世ということもあって人の声を対面で録音することが憚られたので、録音兼ラベル付け用Webアプリをフルスクラッチで実装(フロントエンド・バックエンドを実装)し、クラウドソーシングで募った被験者にURLを渡して行った。合計で700 ~ 800人ぐらいがこのアプリを使った。
構想からリリースまでの期間は大体3ヶ月ぐらい。割と期間に余裕があるように思えるが、もちろん研究以外のこともやってたので、実質1ヶ月ぐらいの工数で進めてたと思う。去年の夏頃に卒論テーマが決まってから全てのデータ収集が完了したのが12月ぐらいなので、メンテ・その他対応(教授からの唐突な要求に答えるための対応や緊急対応など)も含めると期間として半年はアプリのソースコードをいじってた。
ちなみに見せられないよって感じの部分があったり、アプリのURLからクラウドソーシング上のタスクを推測されて変なことされると困るので、見せられるブツは特に無い。別に信用していないわけじゃないよ、ごめんね。その代わり技術的な取り組みをなるべく細かく書く。
アプリの構成
アプリのフロントエンドにNext.js、バックエンドにPHPを使った。
技術選定の根拠を話すと、研究室で借りているさくらのレンタルサーバーでアプリのホスティングを行うことになったのでこうなった。ホスティング先決定前はNode.jsでバックエンドを実装する気満々だったが、このレンサバ上では動かない(参考: https://rs.sakura.ad.jp/function/cgi/#:~:text=%E7%8B%AC%E8%87%AA%E3%81%AB%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0%E3%81%97%E3%81%9FCGI,%E8%A8%80%E8%AA%9E%E3%81%8C%E4%BD%BF%E7%94%A8%E3%81%A7%E3%81%8D%E3%81%BE%E3%81%99%E3%80%82)ので次に慣れているPHPを使った(ちなみにさくらのレンサバでNode.jsが動いたとしても、daemonを実装すると強制停止させられる可能性があるのでどのみち厳しかった気がする: https://help.sakura.ad.jp/206206041/?_ga=2.103246144.1768828013.1642372001-472689077.1617430242)。フロントエンドはNext.jsのSGで書き出した静的ファイルをレンサバの公開ディレクトリに設置するようにした。PHPのコード中にHTMLを絶対に埋め込みたくないというか、あまりPHPを書くモチベがなかったのでNext.jsで解決出来ることは全部これでやった。
以下リポジトリの構成。monorepo風味のプロジェクトになっている。Yarn Workspaceを使って、scripts
には収まらないような適当なCLIツールも一緒に実装出来るようにした。
. ├── README.md ├── babel.config.js ├── data/ ├── jest.config.js ├── package.json ├── scripts │ ├── add-fake-choice.js │ ├── check-listen-num.js │ ├── deploy-client.js │ ├── deploy-server.js │ ├── filter-listen-data-120.js │ ├── format-user-meta-data.js │ ├── generate-dataset.js │ ├── generate-text.js │ ├── group-recorded-data.js │ ├── json2csv.js │ ├── modify-text-ruby.js │ ├── shared/ │ ├── transform-record-result.js │ └── validate-listen-result.js ├── setupJest.ts ├── tsconfig.base.json ├── workspaces │ ├── client/ │ ├── server/ │ └── types-bridge/ └── yarn.lock
workspaces/types-bridge
については この記事 で書いたので良ければ見てほしい。scripts
がてんこ盛りになっているが、アプリ用のデータ準備や収集したデータをデータセットに纏め上げる処理を全てここに集約させた。scripts
で特に見てもらいたいのがgenerate-text.js
の一部で、以下の処理でJNASの新聞記事文のテキストルビのTeXファイルからASTを取り出し、読み上げ文用に加工している。ASTを触る機会が増えていたのでこういう実装をさらっと出来て良かった(再帰関数の中はあまり綺麗ではないが)。
const latexAstParser = require('latex-ast-parser'); // ~~~ let count = 0; /** * @type {string[]} */ let buffer = []; /** * @param {{ type: string, content: any }[]} node * @returns {void} */ const latexNodeVisitor = (node) => { node.forEach((n) => { if (Array.isArray(n.content) && n.type === 'environment') { latexNodeVisitor(n.content); } else { if (new RegExp(/^\d*$/).test(n.content) && n.type === 'string') { if (count > 1) buffer.push(popSidParFour(n.content)); count++; } if ( n.type === 'group' && n.content[0].type !== 'comment' && n.content[0].content !== 'jarticle' ) { buffer.push(n.content[0].content); } } }); }; /** * * @param {(arg: { type: string, content: any }[]) => void} visitor * @returns */ const latexNodeTraverser = (visitor) => { return (ast) => { if (ast.type === 'root') { return visitor(ast.content); } else { throw new Error("Given AST's type is not root."); } }; }; // ~~~ (async () => { // ~~~ const textRubyTexData = await getTextRubyData( 'LONG', TEXT_DATA_CONFIGS.DEBUG_FILE_NUMBER ); const traverse = latexNodeTraverser(latexNodeVisitor); traverse(latexAstParser.parse(textRubyTexData)); // ~~~ })();
レンサバへのデプロイはFTPを使った。ftp-deploy というNPMパッケージがあるので、これを使って以下のような簡単なデプロイスクリプト(scripts/deploy-client.js
)を実装して、Actionsのワークフロー上でフロントエンドのビルド後に実行した。ちなみに dotenv を入れているのは、Actionsが無料枠の天井に到達したときローカルからデプロイするためだったが全然そんなことなかった。サーバーサイドも同様に、書いたPHPのコードをディレクトリごとFTPでレンサバに突っ込んでいる。
const dotenv = require('dotenv'); dotenv.config(); const FTPDeploy = require('ftp-deploy'); const ftpDeploy = new FTPDeploy(); const path = require('path'); const config = { user: process.env.FTP_USERNAME, password: process.env.FTP_PASSWORD, host: process.env.FTP_HOST, localRoot: path.resolve(__dirname, '../workspaces/client/out'), remoteRoot: process.env.FTP_REMOTE_ROOT, include: ['*'], }; ftpDeploy .deploy(config) .then((res) => console.log('finished: ', res)) .catch((err) => console.log(err));
あとは収集したデータをバックエンドで永続化する際、RDBに出し入れするのが面倒だった(レンサバ上で運用するDBのセキュリティ面の知見を持っていなかった)ので基本的にJSONファイル、音声はWAVEファイルにしてレンサバに保存した。
アプリの機能としては大きく分けて2つで「音声の録音」「音声の評価(ラベル付け)」がある。次はそれぞれについて書く。
音声の録音
収集したい音声が以下の形式だった。雑に表現すると"そこそこ質の良い研究に使うにあたって一般的な形式の音声"を収集しようとした。
- サンプリング周波数: 48kHz
- チャンネル: モノラル
- 量子化ビット数: 16bit
- ファイル形式: WAVE
音声の録音にはMediaStreamRecording APIという、WebRTC関連のWeb APIを利用した。ここで1つ問題になるのが、被験者は各々使いたいブラウザを使用して音声を録音することで、あるある〜って感じだがやはり悩まされた。とりあえずpolyfill入れておくかと https://github.com/ai/audio-recorder-polyfill を入れたが、後々になって別にIEは注意書きで使わないように言ったので必要なかった気もする。
また、Firefoxでは NotSupportedError: AudioContext.createMediaStreamSource: Connecting AudioNodes from AudioContexts with different sample-rate is currently not supported.
の通り、サンプリング周波数が44.1kHzから変えられなかったので、仕方なしにアプリの注意書きにFirefoxを使わないように書いた。Firefoxの使用を禁止したのは今までで初めてだった。
なお、このエラー を調べるとこういうGitHub issue上でのやり取りが出てくる。Firefox側で対応されたらしいが、自分のmacOS Catalina環境や学内のCentOS 7環境ではバグっていたので直ってない気がする。両方ともOSが古いのも原因としてありえそう。
とりあえずこれらのことは置いておいてpolyfillを使いつつuseRecorder
フックを実装し、録音を行う実装を含むReactコンポーネント内で利用するようにした。このフックはhttps://codesandbox.io/s/81zkxw8qnlを参考にした。
/** * https://codesandbox.io/s/81zkxw8qnl * https://developers.google.com/web/fundamentals/media/recording-audio * NOTE: ↑ No support for wav format. */ import { useEffect, useState } from 'react'; import AudioRecorder from 'audio-recorder-polyfill'; import type { TextData } from '../components/RecordPage'; const requestRecorder = async () => { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const recorder = new AudioRecorder(stream, { sampleRate: 48000 }); return recorder; }; export const useRecorder = () => { const [audioData, setAudioData] = useState< { id: number; text: string; ruby: string; url: string; formData: FormData; }[] >([]); const [isRecording, setIsRecording] = useState(false); const [currentId, setCurrentId] = useState(0); const [currentText, setCurrentText] = useState(''); const [currentTextRuby, setCurrentTextRuby] = useState(''); const [recorder, setRecorder] = useState<typeof AudioRecorder | null>(null); const startRecording = async (data: TextData[number]) => { setCurrentId(data.id); setCurrentText(data.sentence); setCurrentTextRuby(data.ruby); setIsRecording(true); }; const stopRecording = () => { setIsRecording(false); }; const onSuccess = async (stream: typeof AudioRecorder) => { setRecorder(stream); }; const onError = (reason: any) => { console.error(reason); alert('エラーが発生しました。ページを再読み込みしてください。'); setCurrentId(0); setCurrentText(''); setIsRecording(false); setRecorder(null); }; const handleOnDataAvailable = async (event: BlobEvent) => { /** * NOTE: 以下の WAV データに変換 */ if (event.data.size > 0) { // プレビュー用の URL 作成 const url = URL.createObjectURL(event.data); // サーバーへ送信用の FormData 作成 const formData = new FormData(); formData.append('audio_data', event.data, `${currentId}`); setAudioData((prevState) => [ ...prevState, { id: currentId, text: currentText, ruby: currentTextRuby, url, formData, }, ]); setCurrentId(0); setCurrentText(''); } }; const removeAudioData = (id: number) => { setAudioData((prevState) => prevState.filter((state) => state.id !== id)); }; useEffect(() => { if (recorder === null) { /** * 録音ボタンが押された状態であれば初回 getUserMedia でマイクへのアクセスの許可と AudioRecorder のインスタンス作成 */ if (isRecording) { requestRecorder().then(onSuccess, onError); } return; } if (isRecording) { recorder?.start(); } else { recorder?.stop(); } recorder.addEventListener('dataavailable', handleOnDataAvailable); return () => { recorder.removeEventListener('dataavailable', handleOnDataAvailable); }; }, [recorder, isRecording]); return { audioData, isRecording, startRecording, stopRecording, currentText, removeAudioData, }; };
ちなみにコードの冒頭にごちゃごちゃ書いているが、https://developers.google.com/web/fundamentals/media/recording-audio の例で示されている方法では WAVE ファイルとして音声を録音出来ない(.wav
にはなるがffmpegとかで確認すると壊れているし、そもそも再生出来ない、wave surferで表示すら出来ない)。直そう直そうと思って全然やる時間が無いので、コントリビュートチャンスに飢えている誰かいればやってほしい。
あとはフックを使っていい感じに録音UIを組めばおわり。Firefoxの件以外はそこまで苦労しなかった。
音声の評価
被験者による主観評価によって音声へのラベル付けを行った。評価のUIは各項目1つだけ選択するアンケート形式で実装したかったので、react-hook-formを利用した。
実装したReactコンポーネントはこんな感じ。
import { SubmitHandler, useForm, UseFormRegisterReturn } from 'react-hook-form'; import { LISTEN_CONTENTS } from '../../configs'; import { EuterpeButton } from '../EuterpeButton'; import { ColorParette } from '../Instruction'; import styles from './EuterpeRadioForm.module.scss'; export type SelectionsInput = { [K in keyof typeof LISTEN_CONTENTS]: keyof typeof LISTEN_CONTENTS[K]['selections']; }; type CheckboxInputProps = { value: keyof typeof LISTEN_CONTENTS[keyof typeof LISTEN_CONTENTS]['selections']; formRegister: UseFormRegisterReturn; }; type FieldProps = { contents: typeof LISTEN_CONTENTS[keyof typeof LISTEN_CONTENTS]; formRegister: UseFormRegisterReturn; }; type Props = { onSubmit: SubmitHandler<SelectionsInput>; inputDisabled: boolean; }; const RadioInput: React.FC<CheckboxInputProps> = ({ value, formRegister }) => { return ( <input type="radio" value={value} {...formRegister} className={styles.field_radio} /> ); }; const Field: React.FC<FieldProps> = ({ contents, formRegister }) => { return ( <div className={styles.field_wrapper}> <span className={styles.field_question}>{contents.question}</span> {Object.keys(contents.selections).map((key) => ( <label key={key} className={styles.field_container}> <RadioInput value={key as keyof typeof contents['selections']} formRegister={formRegister} /> <span> {contents.selections[key as keyof typeof contents['selections']]} </span> </label> ))} </div> ); }; export const EuterpeRadioForm: React.FC<Props> = ({ onSubmit, inputDisabled, }) => { const { register, handleSubmit } = useForm<SelectionsInput>(); return ( <form onSubmit={handleSubmit(onSubmit)}> <Field contents={LISTEN_CONTENTS.consistency} formRegister={register('consistency', { required: true })} /> <Field contents={LISTEN_CONTENTS.recordingConditions} formRegister={register('recordingConditions', { required: true })} /> <Field contents={LISTEN_CONTENTS.easeOfListening} formRegister={register('easeOfListening', { required: true })} /> <EuterpeButton type="submit" text="⏩ 次へ" bgColor={ inputDisabled ? ColorParette.DISABLED : ColorParette.ACCENT_GREEN } disabled={inputDisabled} /> </form> ); };
バックエンド
正直あまり書くことがない。本業ではないという言い訳をしながら、歴戦のPHPerが見ると卒倒しそうなコードを書いた。レンサバ上でcomposer走らせるとかが出来なかったので、とりあえず FormData で送信されてきた音声と評価結果などの各種テキストデータを書き出す処理をそのままのPHPで書いた。
レンサバで composer を使えないものの手元では使えるので、PHPStanの静的解析で型チェックをしたり、PHP-CS-Fixerでコードのフォーマットぐらいはやった。
あと意識したのがセキュリティで、Laravelとかのフレームワークに乗っからなかったので全部調べて自分でどうにかした。Qiitaで@tadsanさんや@rana_kualuさんあたりの記事を見たらマシなPHPが書けた。
例えばPOSTされた評価ラベルデータを書き出すとかだとこういう感じでPHPを書いた。
<?php declare(strict_types=1); error_reporting(E_ALL); include dirname(__FILE__) . "/../shared/mkdir.php"; include dirname(__FILE__) . "/../shared/http_variables.php"; include dirname(__FILE__) . "/../shared/console.php"; include dirname(__FILE__) . "/get_data.php"; header('Content-Type: application/json; charset=utf-8'); header('X-Content-Type-Options: nosniff'); $http_variables = new HttpVariables(); $mkdir = new Mkdir($http_variables); $mkdir->set(); $get_data = new GetData(); (function () use ($mkdir, $http_variables, $get_data) { $console = new Console(); $files = $http_variables->files('result_data'); $target_dirname = $http_variables->get('target_dirname'); $target_data_path = realpath('../record/data/' . $target_dirname); $data_map_json_path = $target_data_path . '/data_map.json'; $console->log($data_map_json_path); $data_map = $get_data->get_target_data_map($data_map_json_path, $target_dirname); $data_map['listen_num'] += 1; file_put_contents($data_map_json_path, json_encode($data_map, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); $input = $files['tmp_name']; $output = $files['name'] . ".json"; if (move_uploaded_file($input, "./" . $mkdir->full_data_dir_name . "/" . $output)) { echo json_encode(['message' => 'Created listen result data.']); } else { http_response_code(405); } })();
JSerは即時関数を使いたがるの、あると思います。PHPは慣れていると言いつつも飛び道具程度にしか書いてなかったので、マジでこの部分危ないよ!とかのマサカリ大歓迎です。歴戦のPHPerの元で修行したいが、PHPで飯を食うつもりが今の所ないのでこの有様です。
振り返り
Next.jsをVercel以外でホスティングするのはやっぱり少し面倒だった。今回はただSGするだけなので、next.config.js
で assetsPrefix
を設定するなど適当なCDNに乗せるみたいなイメージでやってた。
反省としては、同時接続の件数がかなりあったのに対応出来ていなかったところがある。収集できたデータを確認していると同時にリクエストが飛んできて書き込みに失敗し、破損しているデータがちらほらあった(これらはクラウドソーシングで再発注した)ので、やっぱりDB使おうねという気持ちになった。DBまわりのセキュリティ勉強する。
卒論執筆を支えた技術
ついでに卒論執筆環境についてもちょっとだけ工夫したので書く。
卒論はTeXで書いた。周りはCloud LaTeXなどのオンラインのサービスを使っていたが、出先で急に「卒論が書きたい!」って思ったり、研究室で色々あってWiFiがダウンしたりしたときに面倒なのでローカルに環境を作った。ただ、ローカルだとデータが吹っ飛ぶと絶望する羽目になるので、GitHub上にプライベートリポジトリを作って適当な進捗が出る度にpushするようにした。
環境構築
mac勢なので学部2回生の頃ぐらいにとりあえずTexShopをインストールしてた。ちなみにこれに付属してるエディタは卒論では使ってない。おわり。
エディタで論文のプレビューを見れるようにする
こっちに注力してめっちゃよかった。
エディタには慣れているVSCodeを使っていた。VSCodeにはLaTeXにむけた拡張機能でLaTeX-Workshopというものがある。これを導入すると、VSCode上でTeXのビルド・プレビューを行いながら執筆出来る上にコード補完とかもある全部のせセットなのでかなり捗った。
添削にむけた差分強調
卒論を書くにあたって指導教員から添削をもらってマシな論文に近づけていくと思うが、その際に何度も繰り返し添削を受けることになると思う。そこで、前回修正した点と今回修正した点が一目見て分かるようにすると教員側に対して優しいのでやった(というかこれをやらないと添削してもらえなかった)。
latexdiffを用いると、訂正前と後の差分をまとめたTeXファイルを吐き出させることが出来る。また、gitを使ってバージョン管理している場合はlatexdiff-vcのgitオプションを使用すると、コミットごとに差分をまとめることが出来る。
これで解決!といきたいところだが、僕の手元のLaTeX環境ではlatexdiff(latexdiff-vc)を利用して吐き出したファイルのfigureが表示されない(多分\usepackage[dvipdfmx]{color}
加えたら表示されるが、めちゃくちゃ焦ってて気づいてなかった)とか、文章に謎の改行が入るとか、差分を出すには若干手間だったので断念した。
代わりにhomebrewでインストール出来るdiff-pdfでPDFの差分を見つつ、一つ一つ手作業で変更箇所の色を変えた。TeXで色を変更したい箇所は\newcommand{\red}[1]{\color{red}#1\color{black}}
のようなマクロを使って\red{変更箇所}
のように書いた。
他に卒論を支えたもの
技術以外の話。
睡眠・運動
睡眠1番大事。徹夜はやめたほうがいい。20代は無敵!とか思ってたが意外と簡単に身体は壊れる。パソコンは雑に扱っても壊れないが、人は雑に扱うと壊れるって昔大学の先輩が言っていた。
とりあえずなんか不調だなと思ったら寝るようにした。人とコミュニケーション取りたくない(Slackやメールの返信がめちゃくちゃしんどいとか)って思った時はかなり疲弊しているので寝ろ!みたいなのをどっかで見た気がする。これを実践していた。
あとは運動すると眠くなりやすいのでおすすめ。合法的に暴れられるのでストレス発散にもなって心身に良い。余裕のあるときは駅前のエニタイムフィットネスに1日おきぐらいで通ってた。別にステマではないけど24時間開いてるのが助かった。
人と話す・飯食いに行く
1人で作業してて煮詰まったら人と他愛もないことを話すと良かった。話すために研究室に行って卒論書いてたり、友達とdiscordで作業したりしてた。
人と飯食いに行くのも気分転換によくやってた。飯の際の酒は程々に。2日酔いになると詰みます。
(適度な)ゲーム
適度ならかなり良い気分転換になった。友達と本気でApexのランクマッチやったり、最近配信するのを勧められて暇なときにTwitchで配信してみたりしてる。やるなら気軽にはじめられて1人でも他の人と一緒でも出来るゲームがおすすめ。のめり込むとこちらも飲酒と同様に詰む。
(適度な)散財
こっちも適度にやると気分転換になる。何回かミスってクレカ上限突破グレンラガンした。
最近だと春服とかメンズコスメとか、春から始まる生活で便利そうなものとかを爆買いした。買い物しながら大学卒業後の生活を想像すると楽しい。卒論で煮詰まったら自転車操業にならないように気をつけつつ、好きなものに対しての散財はぜひ。
おわりに
以下著者近影。笑顔の練習をしていたのでうまく笑えている気がする。
とにかく卒論の修正と発表会頑張る。