retarfiの日記

自然言語処理などの研究やゴルフ、音楽など。

新卒で社会人博士として入学しました

はじめに

本記事は社会人学生 Advent Calendar 2022の6日目の記事です。
Advent Calendarに参加するのは初めてなので、お手柔らかに見ていただけますと幸いです。
本記事では、新卒で入社した年に社会人博士(以下社D)として入学した者としての経緯や実感を述べたいと思います。

軽く自己紹介

2022年3月に東京大学大学院の修士課程を卒業し、4月より資産運用会社で働いております。
10月より、東京大学大学院の博士課程に入学して、現在に至ります。
研究内容としては、金融分野の自然言語処理(テキストマイニング)をやっています。
詳細を知りたい方はhttps://msuzuki.me/をご覧ください。

博士課程入学の経緯

学部・修士課程の頃から現在(博士課程の)の研究室に在籍していました。
博士課程へ進学することに興味はあったものの、

  • 一人暮らしがしたかったので経済的に自立する生活がしたかった
  • 博士課程で精神的に厳しくなることが想像された(成果が出ないなど)

ことから博士課程に進学せず、就活をして(別の企業に)内定を頂いておりました。
共同研究をしていた現在の勤務先に、その旨を伝えたところ、共同研究先の方(現在の上司)は博士課程に進学すると思っていたそうで、それであれば博士課程に進学しつつぜひうちに来てくれないか、という話になりました。 ゼミに出る時間(所属する研究室は平日午前)は勤務ができないため、その旨も了承していただき、社Dを目指す運びとなりました。

しかし、そのお話を頂いたのが2021年10月で、私の所属する大学院の専攻は毎年8月にしか入試がありませんでした。
そのため2022年8月に院試を受け、2022年10月から進学を目指すことになりました。
(そのせいでブランクが生まれ、新たに入学金を払わねばならなくなりました、、、)

博士課程入学前にやったこと

【単位の取得】

私の所属する大学院の専攻では、博士論文以外に10単位の取得が必要になっています。
学生時代はきちんと授業に出席することに何のハードルもなかったものの、社会人として勤務している時間に中抜けして授業を受講することは流石に憚られます。
土曜日や夜に開講する科目もありますが、それだけで全ての単位を取り切るのは容易ではないと思います。
また一時は全てがオンラインだった授業も、教室で受けることが要求される科目が増えてきたため、社会人になってから単位の取得することは困難だと感じました。
もちろん研究もしなくてはなりません。
幸い所属する専攻では単位の持ち越し制度があったため、修士2年の秋学期に修論を書きながら10単位分の授業を受講しました。
(難しい授業があり落単の危機を感じたため実際には12単位を取得しました)

【ジャーナル】

所属する専攻の博士課程の卒業要件として、暗黙的にジャーナル3本のacceptが必要とされているそうです(真偽は定かではないですが)。
博士論文の内容が十分な場合でもジャーナルの要件で引っかかると困るので、研究室でジャーナルを出すことを勧められていました。
そのため修士時代に1本ジャーナルを書きました(卒論の内容でも別に1本書いています)。
M2の6月に一度rejectされましたが、修論を書きつつ別の雑誌に投稿し、そちらも無事acceptされています。
また直近では修論の内容をextendしてジャーナルに投稿した論文がacceptされました(こちらは今publication process中です)。
修士の卒業から博士の入学まで半年ありましたが、論文執筆等は少しずつ続けており、それがどこかで生きてくるのかも、と思っています。

就業環境

社Dをされている方々にとっては、就業環境が研究環境を左右すると言っても過言ではないと思います。
また人によって様々ではありますが、比較しやすいように私の就業環境を記載しておきます。
あくまで私の場合で、私の所属する部署や会社一般とは異なります。

私の職場ではフレックス制度が導入されており、1ヶ月に所定の勤務時間をクリアすれば良いことになっています。
そのためゼミの日には勤務時間を短くし、他の平日に少し長めに勤務しています。
私の職務はテキストマイニングに関連したリサーチが主になっています。
共同研究をしていた関係から業務と研究分野がかなり近く、研究分野にも関連した論文を業務中に読むこともあります。
必須のミーティングも少なく、残業も全く無いです。
職務柄リモートワークもしやすく、会社に行くのは2週間に1,2回のペースです。
そのため仕事後(洗濯物などの家事をしつつ)すぐに研究に取り掛かることができます。
この辺りはだいぶ大きいと思います。

新卒として社D生活を2ヶ月送った感想

前述した背景を踏まえ、私が新卒で社Dを過ごして感じたことを述べたいと思います。
嬉しいことと辛いことを分けて挙げると以下のようになります。

辛いこと

  • 研究時間が足りない
  • 忙殺されがち

嬉しいこと

  • 生活が安定する
  • 単位取得が必要ないのは大きい(私の場合)
  • 研究の勘所と環境があるまま博士課程に入れたのは大きい
  • 謎の新規性を感じられる
  • 1.3倍くらい人生を生きている気がする

【研究時間が足りない】

社会人博士あるあるだと思いますが、だいぶ時間がないです。
平日勤務後、家事等こなしてゆっくり研究できるのは長くても2~3時間です(これでもだいぶ長い方だと思います)。
修士の頃、昼前に大学に行きだらだらやって18時に研究室を出ても5時間程度はやっているわけで、かなり時間が少なくなります。
土日の両方とも研究していればある程度進められますが、どちらかに予定を入れてしまうと、平日研究できなかった分をリカバリーできません。

【忙殺されがち】

前述したように研究時間が足りないため、生活のほとんどの時間で研究について考えるようになりました。
勤務時間以外の余暇の時間を削って研究をすることになるので、趣味だったり交友関係だったり色々諦める必要があります。
私の場合は楽器演奏がゴルフに次ぐ趣味だったのですが、全く楽器に触れる時間がなくなりました。
ゴルフは社会人になっても比較的続けやすい(交友関係にもつながり、運動にもなり、練習も自分のペースでできる)ので、少しずつ続けています。

忙しいので、体調管理や健康も疎かになりがちです。
勤務もほとんどリモートワークのため、家にずっといる日はリングフィットアドベンチャーをしたり、大学には自転車で行くなど少しでも運動するようにしています。

【中途半端に色々足りない】

同じ博士の学生と比較する条件で私には2種類の対象がいると考えています。

  • フルタイムの博士課程の学生
  • 社会人経験を10年近く積んでいる社D

前者のフルタイムの博士課程の学生と比較すると、持っているスキルセットはほぼ同じなのに、働いているために研究時間が足りず、進捗に大きく差が出ます。
また後者の社Dは、同様に研究時間はないものの、積んできた社会人経験から研究の背景(必要性)やストーリーの設定の筋が通っており、議論もしっかりできるためコンプレックスを感じることが多々あります。
このようにがむしゃらに研究ができるわけでも、ストーリーがきれいで(筋を間違えない)対時間の効率が良い研究ができるわけでもないので両方にコンプレックスを覚えます。

【生活が安定する】

社Dをする動機の大きな要因だと思います。
私の場合実家を出て暮らしつつ、憧れだった博士課程に入学することができました。
最近副業できるようにはなりましたが、DC1だけだと都内で学費を払い、年金を払い、一人暮らしをしていくことは困難だと思います。
そういった意味で衣食住に悩むことなく生活できるのはありがたいです。

金銭的な面だけでなく、メンタル的にも安定する要素はあります。
生活軸が研究と仕事の両面があるため、どちらかがうまくいかなくても、「まあもう片方があるからしょうがないか」と思えるようになります。
というか、そう思わないとやってられません。
超人はどちらもうまくやっちゃうんでしょうけど、私には無理です。
※なお、時間的にどちらかで忙殺されている場合はどちらもうまくいかず共倒れになる可能性があります。

【単位取得が必要ないのは大きい】

これは私特有の場合の話ですが。
10単位は、最低でも1学期に1コマは取らないと卒業できないペースです。
講義に出席するだけでなく、期末にはテストや課題がある講義がほとんどだと思うので、こういったことに時間を取られなくて良くなったのはアドバンテージとして大きいと思います。
もしも同じ大学や研究科に戻る予定の人がいれば、在学中に先に単位を取っておくのは手だと思います。
学生でない期間がある場合や単位を引き継げるのかについては必ず学務課に確認しましょう。
私は研究科の事務室に直接メールで確認を取りました。

【研究の勘所と環境があるまま博士課程に入れたのは大きい】

卒業してすぐに博士課程に入ったことや、修士から研究室や分野を変えなかったことで、
研究の最新動向や研究環境をほとんど失うことなくスタートすることができました。

この辺り、社Dにとって大変なところだと思います。
修士と異なる分野だとそもそも最新の論文が何なのかわからない可能性があります。
また新しい研究室だとその研究室のルール、計算環境など順応すべきことが多数あります。
これらに慣れていくうちに半年が経過してしまう、、、ということに陥らなくて済みました。

【謎の新規性を感じられる】

社Dをやる方はある程度社会人経験を積み会社内の調整もされた方がほとんどだと思います。
そんな中新卒で社Dをやるという経験は中々できないと思います。
若いうちに両方経験できるのは希少だし、それこそが新しい経験として価値があると思います(信じています)。
一方でなぜ新規性が出てしまうかというと、【中途半端に色々足りない】項目を始めとして色々大変だからだと思うので、そういったことに今後直面していくのだと思います。
ここでは研究の新規性の話はしてはいけません

【1.3倍くらい人生を生きている気がする】

業務もこなし、研究もしているのだからきっとめっちゃ頑張っています。
今までの自分の1.3倍くらいは頑張っています。
毎日たくさん褒めてあげましょう。
ちょっとうまくいかない日だってあるさ。

まとめ

最後はポエムっぽくなってしまいましたが、新卒で社Dを始めた人の感想を述べました。
出来るかどうかについては、就業先や研究室の環境による部分がかなり大きいと思います。
その上で当人がまずするべきことは健康を維持することだと思います。
不健康だと研究も仕事もどっちも失いかねないので。
そのため、私の当面の健康上の目標としては

  • 多分散歩が考える上で大事
  • しっかり睡眠を取る

これがしっかりできていれば、何とかなると信じています。

HuggingFaceのtransformers.trainerをDeepSpeedと一緒に使うときの注意覚書

事前学習関連で色々試していたらHuggingFaceのtransformersとDeepSpeedのIntegrationでうまくいかないところがあった。 具体的には、transformers.TrainerとDeepSpeedを同時に使っていて、さらにgraidient_accumulation_stepsが1でない場合に、transformers.TrainerとDeepSpeedのそれぞれのglobal step数がずれてしまうというものである。

transformersのv4.24.0ではL1767以降でoptimizerの更新を行う。

https://github.com/huggingface/transformers/blob/v4.24.0/src/transformers/trainer.py#L1767-L1771

しかし、DeepSpeedを使っている場合にはここでは更新が行われない(L1801,1802)。 ではどこで更新しているかというと、L1767-L1771のif文の外側(L1764,1765)である。 このとき、epochの真ん中で回っている分には問題はないのだが、transformersではepochの終わりに必ずoptimizerを更新し、global stepを進めてしまう(L1770)。 しかし、DeepSpeedにはepochなんてものはわからない(各ステップ与えられたデータを淡々と処理する)ので、epochの最後だろうが(step + 1) % args.gradient_accumulation_steps != 0であればoptimizerは更新されない。

簡単な例として、gradient_accumulation_stepsが2で1 epochでは3回forwardがされるとする。 HuggingFaceのみ(正常)の場合は、以下のように動く。 count-global_steps(HF)-epoch-steps(HF) 0-0-0-0 1-0-0-1(optimizer.step()) 2-1-0-2(optimizer.step()) 3-2-1-0 4-2-1-1(optimizer.step()) 5-3-1-2

しかし、DeepSpeedを使うと2列目と5列目がずれていることがわかる。 count-global_steps(HF)-epoch-steps(HF)-global_steps(DS)-steps(DS) 0-0-0-0-0-0 1-0-0-1-0-1(optimizer.step()) 2-1-0-2-1-0(epochの最後なのでHuggingFaceではglobal_stepsが1増えるが、DeepSpeedではoptimizer.step()は行われない) 3-2-1-0-1-1(optimizer.step()) 4-3-1-1-2-0 5-3-1-2-2-1(optimizer.step())

一応これはTrainingArgumentsのdataloader_drop_lastをTrueにすることで解決できると思われる。

当該箇所を解決するには、DeepSpeedを利用しない場合についてのif文を追加してepochの最後に関する条件を

if (not self.deepspeed and ((step + 1) % args.gradient_accumulation_steps == 0 or (
    # last step in epoch but step is always smaller than gradient_accumulation_steps
        steps_in_epoch <= args.gradient_accumulation_steps
        and (step + 1) == steps_in_epoch
    ))) or (self.deepspeed and self.deepspeed.tput_timer.total_step_count % args.gradient_accumulation_steps == 0):

などとすると良いと思う。 多分、gradient_accumulation_stepsの境目かの判定でself.deepspeed.tput_timer.total_step_countよりもTrainer側のself.state.global_stepを使ったほうが良いかも。 この辺ちゃんと検証したら、transformersにプルリク出してみたいとは思う(OSSプルリクしたことないが)。

transformersのDataCollatorForWholeWordMaskについての覚書き

以前BERTやELECTRAを日本語で事前学習するリポジトリを作った
(https://github.com/retarfi/language-pretraining)のだが、
その際に参考にしたtransoformersのversionは4.7.2だった。
v4.7.2では、DataCollatorForWholeWordMaskの実装が間違っていたため、自分で書き直していた
(https://github.com/retarfi/language-pretraining/blob/v1.0/utils/data_collator.py#L49)。
今回バージョンアップにあたって再度v4.22.2の実装を見ていると、実用できそうな実装になっていた。 その思考過程をメモとして残しておく。

まずそもそものMaskingの概要として、BERTを例にとって説明する。
BERTは全tokenのうち15%をMaskingの対象とする。
ここで注意すべきはこれらが全て[MASK]トークンに置き換わるわけではないということ。
この15%のトークンのうち、更に80%(全体で15%x80%=12%)が[MASK]トークンに置き換わる。
残りの20%(全体の3%)のトークンのうち、更に10%(全体で1.5%)がRandom Replaceの対象に、
また最後に残った10%(全体の1.5%)がそのままのトークンとして残る(As it is)。
これら3種類のトークンが事前学習におけるMasked Language Modelという、もとの単語を予測するタスクの対象となる。
そして、Whole Word Mask(以下WWM)では、サブワードのトークンではなく、単語ごとにMaskを行うかの判定をする。
https://github.com/google-research/bertと同様に、以下に例示する。
例えば以下のInput Textに対して通常はその下のOriginal Masked Inputを与える。

Input Text: the man jumped up , put his basket on phil ##am ##mon ' s head
Original Masked Input: [MASK] man [MASK] up , put his [MASK] on phil [MASK] ##mon ' s head

ここで、phil / ## am / ##mon という1語に注目すると、真ん中の ##amのみが[MASK]トークンに置換されている。
これだともとのトークンを予測するのが簡単なため、WWMでは phil / ## am / ##mon の全トークンを[MASK]で置換する。

Whole Word Masked Input: the man [MASK] up , put his basket on [MASK] [MASK] [MASK] ' s head

ここで問題となるのは、ReplacedやAs it isなトークンも、1語単位で行うべきかという点である。

まず、以前の自分の実装では[MASK]トークンに置き換わる確率(上記では12%)を計算し、ランダムに1語ずつ積んでいって合計のトークン数が12%になったところまでを[MASK]トークンになるように置換している。
https://github.com/retarfi/language-pretraining/blob/v1.0/utils/data_collator.py#L118
一方、ReplacedやAs it isについては単語ではなくサブワード単位で行っている(つまり1語内でReplacedされるトークンとそうでないトークンが共存しうる)。

次に、transformersのv4.22.2の実装を見てみる。
v4.10頃から、PyTorch, TensorFlow, Numpyと分けて実装されているため見づらいが、ここではPyTorchに絞る。
https://github.com/huggingface/transformers/blob/v4.22.2/src/transformers/data/data_collator.py#L769
すると、masked_indicesで単語ごとにmaskingの対象にしたトークン列(masked_indices)のうちから、ランダムで80%を[MASK]トークンに置換する実装になっている。
これでは、1単語の中で[MASK]トークンに置換されるトークンとそうでないトークンが共存してしまう。
また、ReplacedやAs it isについてはmasked_indicesの残りから選ばれるようになっている。
そのため、[MASK], Replaced, As it isを合わせるとWWMが維持されるが、その中ではばらばらになってしまう。

最後に、本家であるgoogleの実装を見てみる。
https://github.com/google-research/bert/blob/master/create_pretraining_data.py#L369
この実装では、transformersのv4.22.2の実装と同様、まず単語ごとにMaskingの対象となるトークンを抽出する。
そこから(1単語ごとの制約を設けずに)各トークンについて80-10-10の割合で分けている。
そのため、この実装でも1単語内の[MASK], Replaced, As it isがばらばらになることとなる。

以上を踏まえると、transformersの現在の実装はgoogle本家と同じで正しいといえる。
自作のWWMは廃止するか、、、

本当に簡単なゴルフ場を探す

ゴルフを始めてあまり経っていない人とラウンドに行く機会が増えました。
ゴルフ場を予約するときに気になってきたのが、コースの難易度です。
特に始めたての頃は、コースが難しすぎると大変です。
(もちろん自分のスコアも良くはないのですが)
なので、なるべく簡単なコースを探そうと思って調べるものの、
巷のコースを紹介しているページでは、本当に簡単なのか?というコースもあったりします。

そこで今回はGDOゴルフ予約の情報を集約して、データから易しいコースで、お値段も高すぎないコースを探せるようにしたいと思います。
GDOの口コミでは、フェアウェイの広さや難易度の他に、GDOスコアから収集した各スコア帯(96-105など)の人の平均スコアを見ることができます。
これらを見れば、より定量的にコースの難易度を比較できます(各コースに来る各スコア帯の人の本当の実力の分布が同一であるという仮定は必要ですが)。

やること自体は複雑ではありません。
手順としては、

  1. ゴルフコースの一覧を作成する
  2. 各ゴルフコースについて様々な情報を集める

という流れです。

まず1つ目のゴルフコースの一覧作成について。
GDO予約から各地域の都道府県などを指定して検索すると、以下のようなURLで検索されていることがわかります。

https://reserve.golfdigest.co.jp/s/search/calendar/?region=千葉県&start_time=8時台&start_time=9時台&start_time=10時台&exclude=ハーフプレーを除く&exclude=1人予約を除く&exclude=ジュニアプランを除く&exclude=コンペプランを除く&exclude=オープンコンペを除く&exclude=ナイタープレーを除く&month=9&day=1&during=31&sort=course_rate_total_desc&rows=60

この例では

  • 千葉県で
  • 8,9,10時台のスタートで
  • ハーフプレー、1人予約、ジュニアプラン、コンペプラン、オープンコンペ、ナイタープレーを除いたものを
  • 9月1日から
  • 31日間について
  • 高評価順に
  • 60件

検索しています。
このように、URLのクエリを変えることで様々な条件で検索ができます。
&=:/?を変換しないで行うURLエンコードは必要です。
Pythonならurllib.parse.quote(<変換したいURL>, safe="&=:/?")で実現できます。
使えるクエリの種類については、検索画面のプルダウンやチェックをいろいろいじればわかります。
60件より多い件数の場合、&page=2などを追加すると適当なページを表示できます。
このようにして各ゴルフ場を表示し、これらのリンクから各ゴルフ場の名前、id(URLの末尾)、ICからの距離などを取得します。
実際の抽出はPythonではrequests+BeautifulSoupなどでできます。

次に、各ゴルフ場のidがわかれば、口コミや予約カレンダー、詳細情報のページをスクレイピングすることで情報を集められます。
例えば赤羽ゴルフ倶楽部(id:360101)であれば、URLはそれぞれ

のようになっています。
各ゴルフ場の、各ページから抽出すれば完成です。
口コミページを見ると一見seleniumなどを使わないと無理そうですが、Pythonのrequestsでも収集できました。
実際のコードについては公開しませんが、8月19日の夜時点で、9月の予約状況をもとに検索した結果をスプレッドシートにまとめましたので公開します。
価格帯やレビュー、平均スコアは時間とともに変わると思いますが、ある程度参考になると思います。
範囲は私が行きやすい県として栃木県、群馬県茨城県、埼玉県、千葉県、東京都、神奈川県に限定しています。
列数が非常に多くなっていますので、適度に非表示にしながらの方が見やすいと思います。

docs.google.com

GitHub Actionsでワークフロー化すれば定期的に更新できるかと考えましたが、
スプレッドシートを更新するのにGCP周りで設定が必要そうなので一旦はこれで置いておきます。