RareJob Tech Blog

レアジョブテクノロジーズのエンジニア・デザイナーによる技術ブログです

NeurIPS2021 outstanding paperのMAUVEを解説

1. はじめに

明けましておめでとうございます。 冬休みに実家に帰省したら記録的な大雪で20cmの積雪を見れて少し嬉しかった、EdTech Labの水谷です。
昨年末の12月はNeurIPS2021がありましたが、その中でoutstanding paperの1つに選ばれたMAUVE: Measuring the Gap Between Neural Text and Human Text using Divergence Frontiersを解説したいと思います。 現在レアジョブでは言語生成モデルは運用していませんが、今後言語生成モデルを扱う際にはその評価の一つとして使える方法なのではと思っています。

2. 背景

GPTMegatronに代表される巨大な言語モデル(Enormous Language Models: ELMs)が自然言語処理の様々なタスクにおいて人間と同等以上の精度を出せるようになっており、特にGPT-2がリリースされた際には人が書いた文章とあまりにも見分けが付かないことが話題になりました
しかし、このようなopen-endな言語生成において、機械が生成した文章と人間が生成した文章がどの程度似ているかの指標に関してはあまり研究がされておらず、ヒューリスティックな評価や人間による評価が行われていました。
そこで、MAUVEでは機械が生成した文章と人間が生成した文章の分布を統計的に比較することで、客観的な言語生成モデルの性能評価の指標を提示しています。また人間による評価とも高い相関を示していることから、人間のアノテーターによる高価で主観的な評価からの脱却の道筋を示しています。

3. MAUVE

人間が生成した文章の分布を P、機械によって生成された文章の分布を Qとするとき、以下の2種類のエラーがある。

  • Type I error:  Q (機械)ではよく現れるが、 P (人間)には少ないテキスト
  • Type II error:  P (人間)ではよく現れるが、 Q ( 機械)には現れにくいテキスト

f:id:emiz6413:20220105143535p:plain
type I errorとtype II errorの概念図 (原論文より引用)

Type I errorは Pから見た Qの乖離度と考えられるので、KL情報量を使って D_{KL}(Q || P) と表され、同様にType II errorは D_{KL}(P || Q)と書くことができる。 しかし、 Q Pでテキストの集合が異なる場合(多くの場合当てはまる)、 D_{KL}(Q || P) D_{KL}(P || Q)のどちらか、もしくは両方が無限大となるため、 R_{\lambda} = \lambda P + (1 - \lambda)Q , \lambda \in (0,1)という R_{\lambda}を導入し、Type I errorを D_{KL}(Q || R_{\lambda})、Type II errorを D_{KL}(P || R_{\lambda})と書き換えることでこの問題を回避している。
このλを(0,1)で変化させた時の、 \exp^{-D_{KL}(Q || R_{\lambda})} \exp^{-D_{KL}(P || R_{\lambda})}の描く曲線(divergence curve)の下の面積がMAUVEとして定義される。

f:id:emiz6413:20220105143731p:plain
divergence curve

具体的には、P(x)及びQ(x)を以下の手順で近似・量子化して計算する 。

  1. GPT-2などの外部モデル Mを使って、人間が生成したテキスト x_{i} 及び機械が生成したテキスト x'_{i}のEmbeddingを得る。

    f:id:emiz6413:20220105144402p:plain
    M(x)を2次元に次元削減した図 (原論文より引用)

  2. 得られた M(x_{I}) M(x'_{I})はそれぞれベクトルであるが、k-meansなどでクラスタリング \phi(x) \in \lbrace 1,...,k \rbraceし、属するクラスタを得る。

    f:id:emiz6413:20220105144605p:plain
    M(x)のクラスタリング決定境界 (原論文より引用)

  3. クラスタの出現頻度をカウントすることで、 P及び Qを近似。例えば、 Pの場合、

     \displaystyle{
\tilde{P}( j ) = \frac{1}{N} \sum^{N}_{i}{\mathbb{I}_{j} (\phi(x_{i}))} \quad where \quad {\mathbb{I}_{j}}(x):=
\begin{cases}
1  \quad \text{if} \quad x \in j \\
0 \quad \text{otherwise.}
\end{cases}
}

こうして P及び Q k個の要素からなる離散的な確率分布として近似+量子化することでdivergence curve = MAUVEが数値計算できる 。

4. 実験による評価

MAUVEの特性を評価するため、言語生成タスクにおいて1. 文章の長さ, 2. decodeのアルゴリズム, 3. モデルサイズを変化させて評価を行った。

4.1 文章の長さ・モデルサイズとの関係

機械により生成される文章の質は、文章が長いほど一貫性を失い低下することが知られており、また、モデルのパラメータ数が多くなるほど生成される文章の質が向上することもよく知られている。
文章の長さとモデルサイズ(GPT2 small~xl)を変化させ、従来のmetricsとの比較を行なったところ、MAUVEは文章長に関しては単調減少し、またモデルサイズに対しては単調増加するという理想的な挙動を示していた。

f:id:emiz6413:20220105150656p:plain
文章長・モデルサイズとMAUVEの関係 (原論文より引用)
※↑の指標は大きいほど2つの入力が近いことを示し、↓の指標は小さいほど2つの入力が近いことを表す。例えば、MAUVEは大きいほど2つの入力が近く、generation perplexity difference(Gen. PPL. diff.)は小さいほど2つの入力が近いことを表す。

4.2 decodeのアルゴリズムとの関係

3つのdecodeのアルゴリズム(greedy, ancestral, nucleus)の違いによる生成文章の質の比較を行なった。 これらのアルゴリズムの原理に関しては、本投稿では割愛しますが、以下の特性が知られています。

  • greedy decoding(貪欲法)は文章がある程度長くなると同じ文を繰り返しやすいという性質が知られており、3つの中では最も質が低いと思われる。
  • greedy decoding < ancestral sampling (beam sampling) < nucleus sampling (top-p sampling)の順に生成文章の質が良くなることが多い。

また、これらに加え、著者らが独自にadversarial samplingというダミーなdecoding方法を比較対象に加えており、これは先頭の15%はランダムにサンプル、残りのtokenはperplexityが人間が生成した文章と同じになるように選択するアルゴリズムで、perplexityは低く保ちつつ、意味の無い文章を生成する。

これら合わせて4つのdecodeアルゴリズムを比較した結果、MAUVEはgreedy < ancestral < nucleusという理想的な挙動を示していた。

f:id:emiz6413:20220105152443p:plain
decodeアルゴリズムの影響 (原論文より引用。一部改)

4.3 人間による評価及びdiscriminatorとの相関

クラウドソーシングで200人以上のアノテーターを使い、人間のアノテーターとMAUVEとの比較を行なった。
この実験では、あるコンテキストとそれに続く2つの文章を見て、より自然に見える(human-like)、興味深い(interesting)、合理的(sensible)な方を選択。2つの文章は8つの異なるモデル(モデルとdecoderの組み合わせ)と人間が生成した文章からなる {}_9C_2 = 36通りの組み合わせがあり、Bradley-Terry(BT)モデルで各モデル(または人間が生成の文章)の優劣度合い w_{i}を算出し、人間のアノテーターとの相関を算出した。 また、機械生成の文章を判別する(学習済みの)discriminatorによる判別精度との相関も計測(% Disc. Acc.)。

従来のmetricsと比べ、いずれの設定においても人間との相関は最も高く、discriminatorとの相関も高いことが確認された。

f:id:emiz6413:20220105162039p:plain
人間のアノテーターと各指標の相関

5. 試してみた

MAUVEのソースコードPyPI上にも公開されており、pipでインストールすることができます。
デフォルトではembeddingの計算にhuggingfaceのGPT2-largeを用いるため、Pytorchとhuggingfaceのインストールします。

pip install torch==1.10.1+cu113 -f https://download.pytorch.org/whl/cu113/torch_stable.html 
pip install transformers 
pip install mauve-text

MAUVEの計算は簡単で、人間と機械が生成したテキストのリストを関数に入力するだけでMAUVEスコアを返してくれます。

from mauve import compute_mauve 
p_text = ["人間が生成したテキストのリスト'", ...] 
q_text = ["機械生成のテキストのリスト", ...]
result = compute_mauve(p_text=p_text, q_text=q_text) 

ここでは、READMEに従い、GPT-2が生成したamazonのレビューのデータセットを使ってMAUVEを計算してみます。

import json 
from mauve import compute_mauve 
import matplotlib.pyplot as plt 

with open('amazon.valid.jsonl') as f: 
    p_text = [json.loads(l)['text'] for l in f] 
with open('amazon-xl-1542M.valid.jsonl') as f: 
    q_text = [json.loads(l)['text'] for l in f] 

print(p_text[0]) 
"""
Category: Cell Phones and Accessories
Product: Blackberry Trackball / Joystick / Navigate / Pearl / Ring Repair Replacement Fix Fixing for Rim Blackberry Pearl
Rating: One Star
Summary: Paid for express shipping and got it almost two weeks later
Review: ask for more money for express shipping and it takes longer than regular mail. It took almost two weeks to receive item. I wrote to seller and seller never contacted me.
""" 

print(q_text[0]) 
""" 
Category: Baby
Product: Sassy Soft Teethers 2 Piece
Rating: Three Stars
Summary: Not so much a teether once my kids started teething
Review: These are somewhat hard, so my two toddlers (3 and 4) didn't get as much out of them as I expected them to. I wish I had gotten something different. Still, not awful.
""" 

out = mauve.compute_mauve(p_text=p_text[:1000], 
                          q_text=q_text[:1000], 
                          device_id=0,
                          verbose=False,
                          max_text_length=256)
print(out.mauve)  # 0.9405395299261428
plt.plot(out.divergence_curve[:, 1], out.divergence_curve[:, 0])

f:id:emiz6413:20220105153446p:plain
出力plot

このように、テキストのリストさえ与えれば簡単にMAUVEを計算することができました。

6. 所感

  • Type I及びType II errorをKL Divergenceとして定義し、うまく発散を回避しつつMAUVEを定義しているところは面白いと思いました。
  • 外部モデルによるembeddingでの入力の近似や、クラスタリングでの量子化など、かなり作り込んでいそうだが、実験結果を見るとよく知られた特性や人間による評価ともよく相関しており、上手く動いているという印象を受けました。
  • クラウドワーカーを使った実験や、パッケージを公開するところまで実装している点にも著者らの多大な努力を感じられました。

英会話レッスンの需要を予測をする

すっかり寒くなってきました。レアジョブ の 技術本部 では現時点で、週2日は出社、それ以外はリモートという環境で仕事をしておりますが、家で仕事する時にはもっぱら小型犬を膝に乗せて暖を取りながら仕事しています。レアジョブ のプラットフォームチームの塚田です。

レアジョブ では 13年運用してきたモノリシックなアーキテクチャーから、マイクロサービスアーキテクチャーへの移行プロジェクトを行いました。

現在は レアジョブ英会話の要である、レッスン機能や検索機能は新アーキテクチャーから提供しております。

マイクロサービスへの移行は一筋縄に行かず課題は多く長い期間がかかりましたが、その甲斐あって新しいスタート地点に立つことができました。

今回の移行で我々はレアジョブ社 の提供する レアジョブ英会話や SMART Method から、予約情報などを一括りにして共通機能として作り、1つのプロダクトに限定しない形で、汎用的な API を設計・開発しました。

従来より使ってきた DB にはレッスンには直接関係しない情報(たとえばキャンペーン情報とか)も入っていたために、データ分析という観点だと今まではDB 構造を把握している人のみが手を入れることができ、そうでない人からすると保守がしづらいという課題もありました。

今回のマイクロサービスへの移行を経て、 1つの DB の中にレッスンに関連する情報のみが入るようになったことで分析する上でも敷居が下がりました。

今回ご紹介する本記事は、レアジョブ で現在運用しているノウハウではありませんが、今までモノリス時代から使っていた需要予測プログラムの改修するのではなく、あくまでもマイクロサービスに移行された情報のみを使って Amazon Forecast を使った需要予測をしてみることにしました。

f:id:sumito1984:20211221012639p:plain

今回使用するデータがオンライン英会話の予約数の履歴を元にした将来の予約数(需要)を知りたいため、Forecasting domain で Retail を選択します。

f:id:sumito1984:20211221012715p:plain

レアジョブ は 30分単位でレッスンを提供しているため、data entries の interval を 30 min にします。

demand は 正数値であるため、Data schema の JSON を編集し integer に変更しました。

f:id:sumito1984:20211221012813p:plain

Timestamp format はyyyy-MM-dd HH:mm:ss を指定しました。

forecast は Timestamp format を以下の2種類から選ぶことになっています。

秒(ss) 抜きでも受け付けて欲しいところです。

f:id:sumito1984:20211221012840p:plain

import する csv ファイルを s3 に upload し、そのパスを指定します。

f:id:sumito1984:20211221012906p:plain

csv ファイルの中身は以下の通りです

ちなみにbook_slot の値は実際サービスで提供している数ではありません。あくまでも本ブログのための仮の数値です。

今回は教師データとして過去3ヶ月分のデータをCSVで入れました。

product,timeslice,book_slot
rarejob,2021-09-10 10:00:00,9180
rarejob,2021-09-10 10:30:00,8500
rarejob,2021-09-10 11:00:00,9143
rarejob,2021-09-10 11:30:00,7920
rarejob,2021-09-10 12:00:00,8104
rarejob,2021-09-10 12:30:00,6273
rarejob,2021-09-10 13:00:00,9246

Create in progress の状態は数時間ほどかかります。

Predictor の作成

Forecast horizon は 未来に渡りいくつ予測値を出すかを指定します。今回は10としました。(30分のレッスンが 10個分という意味になるので、5時間分の予測が作成されます)

f:id:sumito1984:20211221012940p:plain

Input data configuration で 各国の 休日を指定することができます。

祝日かどうかでデータに影響を与える場合設定したほう良いです。

レアジョブ英会話は主に日本の生徒様向けにレッスンを提供しているので Japan を選択します。

f:id:sumito1984:20211221013006p:plain

しばらくすると、create a forecast を選択できるようになっています。

f:id:sumito1984:20211221013035p:plain

Forecast の名前を入力し、 Predictor を選択します。

f:id:sumito1984:20211221013108p:plain

forecast の作成までさらに時間を要します。

完成したら Query forecast を選択できるようになっています。

f:id:sumito1984:20211221013141p:plain

予測開始日、終了日を選択します。

f:id:sumito1984:20211221013222p:plain

f:id:sumito1984:20211221224708p:plain

P10, P50, P90 の説明は AWS の公式ページにある通り、超過確率になります。

P10 (0.1)-真の値は予測値の 10% より低くなることが予想されます。 P50 (0.5)-真の値は予測値の 50% より低くなることが予想されます。 これは、予測値とも呼ばれます。 P90 (0.9)-真の値は予測値の 90% より低くなることが予想されます。

予測精度の評価 - Amazon Forecast

予測データは 未来 10個分表示されています。

これは data horizon が 10 に設定したためで、data horizon の値を大きくすればその分未来の情報を予測することができます。

注意していただきたいのが data horizon の上限は 500ですので、それより先の予測はできません。

数日後確認すると、Amazon forecast で予測された数と 実際の予約数を確認すると P50 にかなり近い実測値になりました。

f:id:sumito1984:20211221225447p:plain

(お見せできない情報を省いたらとてもシンプルな絵になってしまいました。お見苦しくてすみません)

今回は Amazon forecast を通じたレッスンの予測の話しになりましたが、レアジョブ では 共通基盤であるマイクロサービスを Go で開発しております。

まだまだやりたいことがたくさんあるのですが、とても人手が足りずやりたいことがなかなか進めることができない状況です。 一緒にサービスを成長させてくれるエンジニアを募集しています。まずはカジュアルにランチからいかがでしょう。

Go言語エンジニア | アピール | 未来の教育を作る人のマガジン

それでは!

getDisplayMediaを使って画面共有をやってみる

フロントエンドエンジニアの田原です。 今年も残り僅かとなり、年末感が強くなってきたように感じます。 街中に流れる音楽や急にくる寒さなど色んなとところで年末感を感じられるとは思いますが、皆さんはどういったところで感じられるでしょうか?

私は「これはたくさん入ってるので年末までは保つなぁー」という思いで買ったみかん箱が 4 日ぐらいでなくなり、手が黄色くなっている事で年末感を感じることができました。(みかん美味しいよね ♪)

本日は Lesson Room における画面共有機能を開発しておりますので、それについての内容を説明したいと思います。
※尚、以下コードは処理の流れを簡易的に説明したものとなるため、実際の実装とは多少異なります。

目次

getDisplayMedia を使って画面のキャプチャーを取得する。

getDisplayMediaは、それを呼び出す事でディスプレイまたはその一部の内容をキャプチャするための許可設定を選択させる事ができる WebAPI のメソッドです。

画面キャプチャー用の stream(ここでは画面キャプチャーデータの塊のようなもの)を以下のように取得します。

const displayStrem = await navigator.mediaDevices.getDisplayMedia({
  video: true,
})

非同期関数(返り値が Promise object なので)なので await や then で取得した stream の値を取り扱えるようにします。

次に返された stream(ここでは displayStream)を使って映像部分(track)を取り出します。

const [displayVideoTrack] = displayStream.getVideoTracks()

Audio Track との合成

次に getUserMedia で取得した stream(ビデオチャット用のカメラと音声データの塊のようなもの)のうち、 音声の部分(track)を取得します。

stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true })
const [userAudioTrack] = stream.getAudioTracks()

取得した音声の track と画面キャプチャーの映像 track を新しい stream に合成します。

const newStream = new MediaStream([displayVideoTrack, userAudioTrack])

stream を合成されたものに差し替える

最後に上記で作成した新しい stream を今使っている stream に差し替える対応を行います。

尚、弊社では Lesson Room にてリアルタイム通信(WebRTC)を実現する為にSkywayという SDK を利用しており、その中に stream を入れ替えるメソッドが用意されているのでそちらを利用します。参考:replaceStream

skyway.replaceStream(newStream)

これで、送信中の stream が変更され映像領域の内容が変更され送信されるようになります。

画面共有終了時に元のビデオ画面に戻す。

途中抜き出した、displayVideoTrackに対し addEventListener 設定しておくことで画面共有が終了した際(閉じられた)の event の callback 関数を呼び出すことができます。 この callback の中で画面共有で利用した stream(newStream)のビデオ track を停止させ、再度 replaceStream を呼び出してもともとの stream に戻します。

displayVideoTrack.addEventListener(
  'ended',
  () => {
    newStream.getVideoTracks()[0].stop()
    skyway.replaceStream(stream)
  },
  { once: true }
)

画面共有が終了(閉じられると)すると自動的に元のビデオ画面の状態に戻ります。

まとめ

リモートワーク普及により、Zoom や Meet ライクな UX をユーザー様に提供すべく遅ればせながら画面共有の開発を実装しているところです。実際にお使い頂けるようになるまで、もう少し先にはなりますが今後も増える予定の各機能追加を楽しみにして頂きながら Lesson Room を英語学習の一つのツールとしてご利用頂けますと幸いです。

canvasで作るMatrix Rain

フロントエンドエンジニアの田原です。 本日は全世界待望のマトリックス最新作(マトリックスレザレクションズ)の記念して Matrix Rain を教えてもらいながら作って みたのでそちらについて共有させて頂きたいと思います。

目次

はじめに

「教えてくれ、アンダーソン君。コードを書くにはどうすればいいかね?もし、君が成果物を先に出さないんだとしたら?」と皆さんの心の中のエージェント・スミスに言われてしまいそうなので先にどういう結果になるかの URL を貼っておきます。こちら

作り方

まずは殆ど初期状態のindex.htmlstyle.cssを用意します。

※link 先のコードを参考にしてください。

次に js ファイルを用意し、「君は選ばれし者なのだ、ネオ。君は数年前から私を探してきたかもしれないが、私は生涯をかけて君を探してきた」とモーフィアスの気持ちになり自身の心の中にいるネオに語りかけてください 実装を初めてください。

STEP1

canvas dom を取得し、context の設定と canvas 画面の高さ・横幅を取得しておきます。 global 変数として使う為スコープは切っておりません。

const canvas = document.getElementById('canvas')
const ctx = canvas.getContext('2d')
canvas.width = window.innerWidth
canvas.height = window.innerHeight

STEP2

次に Symbol クラスを定義していきます。 コンストラクタには x 軸と y 軸、文字の大きさ、cavnas 画面の高さを設定できるようにします。 流れてくる文字がランダム生成されて代入される用の text 変数とマトリックス文字(?)を初期値として設定しておきます。

class Symbol {
  constructor(x, y, fontSize, canvasHeight) {
    this.characters = `アァカサタナハマヤャラワガザダバパイィキシチニヒミリヰギジヂビピウゥクスツヌ
    フムユュルグズブヅプエェケセテネヘメレヱゲゼデベペオォコソトノホモヨョロヲゴゾドボポヴッン0123456
    789ABCDEFGHIJKLMNOPQRSTUVWXYZ`
    this.x = x
    this.y = y
    this.fontSize = fontSize
    this.text = ''
    this.canvasHeight = canvasHeight
  }
}

更にこのクラス内に draw メソッドを生やします。実際に canvas 画面に描画する文字が塗られるのはこのメソッドの処理によるものです。

// Symbolclassに追加します

draw(context) {
  this.text = this.characters.charAt(
    Math.floor(Math.random() * this.characters.length)
  )

  context.fillText(this.text, this.x * this.fontSize, this.y * this.fontSize)
  if (this.y * this.fontSize > this.canvasHeight) {
    this.y = 0
  } else {
    this.y += 1
  }
}

先程、設定した text の中にまずはマトリックス文字がランダムで入るようにします。 次に引数として渡した context を使い、fillText で文字を描画します。 fontSize 掛け算する事で文字が重ならない座標を指定します。 draw は文字列を描画するために後ほど for でループで回す必要があるので y 軸の座業が canvas の高さを越えないように y 軸をリセットする様にしておきます。それ以外の場合は 1 ずつ座標が増えていくことで y 軸に文字が順に描画されるようになります。

Symbol クラスについてはこれで完成です。

class Symbol {
  constructor(x, y, fontSize, canvasHeight) {
    this.characters = `アァカサタナハマヤャラワガザダバパイィキシチニヒミリヰギジヂビピウゥクスツヌ
    フムユュルグズブヅプエェケセテネヘメレヱゲゼデベペオォコソトノホモヨョロヲゴゾドボポヴッン0123456
    789ABCDEFGHIJKLMNOPQRSTUVWXYZ`
    this.x = x
    this.y = y
    this.fontSize = fontSize
    this.text = ''
    this.canvasHeight = canvasHeight
  }
  draw(context) {
    this.text = this.characters.charAt(
      Math.floor(Math.random() * this.characters.length)
    )

    context.fillText(this.text, this.x * this.fontSize, this.y * this.fontSize)
    if (this.y * this.fontSize > this.canvasHeight) {
      this.y = 0
    } else {
      this.y += 1
    }
  }
}

STEP3

次に Effect クラスを作っていきます。 こちらは先程、作成した Symbol クラスを内部でインスタンス生成し、呼び出していく為のクラスになります。

まず、Symbol クラス同様にコンストラクタに各定義を行い、インスタンス生成用で使う initialize と画面サイズに対応できるように resize メソッドを生やします。

class Effect {
  constructor(canvasWidth, canvasHeight) {
    this.canvasWidth = canvasWidth
    this.canvasHeight = canvasHeight
    this.fontSize = 18
    this.columns = this.canvasWidth / this.fontSize
    this.symbol = []
    this.initialize()
  }
  initialize() {
    for (let i = 0; i < this.columns; i++) {
      this.symbol[i] = new Symbol(i, 0, this.fontSize, this.canvasHeight)
    }
  }
  resize(width, height) {
    this.canvasWidth = width
    this.canvasHeight = height
    this.columns = this.canvasWidth / this.fontSize
    this.symbol = []
    this.initialize()
  }
}

比較的完結な処理になりますが、補足すると columns に canvas で描ける列の数値がはいっており、列数分をループさせて Symbol クラスを使ったインスタンスを symbol の配列に格納しております。

STEP4

Effect クラスを呼び出してアニメーションでループさせる処理を作っていきます。

const effect = new Effect(canvas.width, canvas.height)

次に animation メソッドを作成し色の設定を行います。

const animation = (timestamp) => {
  // 黒く塗りつぶす処理
  ctx.fillStyle = 'rgba(0, 0, 0, 0.05)'
  ctx.textAlign = 'center'
  ctx.fillRect(0, 0, canvas.width, canvas.height)

  // 緑で文字を描画するる処理
  ctx.fillStyle = '#0aff0a'
  ctx.font = effect.fontSize + 'px monospace'
  effect.symbol.forEach((symbol) => symbol.draw(ctx))
}

そしてこのアニメーションを連続で発火させるために関数内部にrequestAnimationFrameを設定します。 ここまでで animation()を呼び出すとのような感じになります。

const effect = new Effect(canvas.width, canvas.height)
const animation = (timestamp) => {
  ctx.fillStyle = 'rgba(0, 0, 0, 0.05)'
  ctx.textAlign = 'center'
  ctx.fillRect(0, 0, canvas.width, canvas.height)

  ctx.fillStyle = '#0aff0a'
  ctx.font = effect.fontSize + 'px monospace'
  effect.symbol.forEach((symbol) => symbol.draw(ctx))

  requestAnimationFrame(animation)
}

animation()

f:id:ssp0727-lnc:20211217120704g:plain
途中

STEP5

最後にマトリックスっぽくします。 各種設定値を設定し、条件を追加していきます。

let lastTime = 0
const fps = 30
const nextFrame = 1000 / fps
let timer = 0

const animation = (timestamp) => {
  const deltaime = timestamp - lastTime
  lastTime = timestamp
  if (timer > nextFrame) {
    ctx.fillStyle = 'rgba(0, 0, 0, 0.05)'
    ctx.textAlign = 'center'
    ctx.fillRect(0, 0, canvas.width, canvas.height)
    ctx.fillStyle = '#0aff0a'
    ctx.font = effect.fontSize + 'px monospace'
    effect.symbol.forEach((symbol) => symbol.draw(ctx))
    timer = 0
  } else {
    timer += deltaime
  }
  requestAnimationFrame(animation)
}

animation(0)

これで、フレームレートの描画速度が調整できるようになりました。 最後に Y 軸の描画がランダムになるように if 文に条件を追加します。 && Math.random() > 0.98を追加しております。

// Symbolクラスのdrawメソッド
if (this.y * this.fontSize > this.canvasHeight && Math.random() > 0.98) {
  this.y = 0
} else {
  this.y += 1
}

f:id:ssp0727-lnc:20211217120912g:plain
完成

Matrix Rain になりました!!

window 幅を変更したときに初期化されるように resize イベントを追加して完成です。

まとめ

これまであまり触れることのなかった canvas ですが、教えてもらいながら少し触ってみて勘所的なものについては少しだけわかった気がします。(でも、まだまだムズい) JavaScript は色々な分野で使うことができるのでやっぱり好きだなぁと思いました。 すぐにでも見に行きたい最新作ですが、ちょっと年末時間がとれなさそうなので年始に見に行こうかなとおもっております。 最後まで読んで頂きありがとうございます。

5分で作るアニメーション付き棒グラフ

こんにちは。ネイティブアプリエンジニアの杉山です。
今回は、アニメーション付きグラフを作成するという小ネタを書いていきます。

今回作るもの

項目が下から「にゅ〜ん」と伸びてくる棒グラフ

レイアウトファイルを用意する

以下のレイアウトファイルを用意していきます。
・ベースとなる画面のレイアウト
・グラフアイテムのレイアウト

ベースとなる画面のレイアウトファイルには、RecyclerView だけを記述しておきます。

<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/graph_recycler"
        android:background="@color/black"
        android:layout_height="match_parent"
        android:layout_width="match_parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

グラフアイテムのレイアウトファイルを用意します。
こちらは単純に View を記述するだけです。
横幅などは、お好みで設定してください。

<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/graph_item_layout"
    android:layout_width="wrap_content"
    android:layout_height="match_parent">

    <View
        android:id="@+id/graph_item_view"
        android:layout_width="16dp"
        android:layout_height="1dp"
        android:layout_marginBottom="4dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

ロジック作り

最初にアニメーションクラスを作っておきます。
ベースの高さから指定した高さに変化させていくというものになります。

class HeightAnimation(var view: View,
                      var startHeight: Int,
                      var targetHeight: Int) : Animation() {

    override fun applyTransformation(interpolatedTime: Float,
                                     t: Transformation) {
        val newHeight =
                (startHeight + (targetHeight - startHeight) * interpolatedTime).toInt()
        if (newHeight == 0)
            return
        view.layoutParams.height = newHeight
        view.requestLayout()
    }
    override fun initialize(width: Int,
                            height: Int,
                            parentWidth: Int,
                            parentHeight: Int) {
        super.initialize(
                width,
                height,
                (view.parent as View).width,
                parentHeight)
    }
    override fun willChangeBounds(): Boolean {
        return true
    }
}

ベースとなる画面の Activity にて、最終的にグラフとなる RecyclerView の設定を行います。
view bind は、公式推奨の形式で行っております。

binding.graphRecycler.run {
            this.layoutManager =
                LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
            adapter = GraphRecyclerAdapter()
        }

次に Adapter クラスを作成します。
今回は、アイテム数を 7 としました。(特に意味はありません。。)

class GraphRecyclerAdapter: RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
            GraphViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.graph_item, parent, false), parent)

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        (holder as? GraphViewHolder)?.configure(position)
    }
    override fun getItemCount(): Int = 7
}

最後に ViewHolder で、グラフアイテムの設定を行います。
ここで最初に作成したアニメーションクラスを設定していきます。
他はコード内にコメントを記入したので、そちらをご覧ください。
背景の設定などをコードで行っておりますが、Drawable にリソースファイルを作って指定していただいても構いません。

class GraphViewHolder(private val containerView: View,
                      private val parent: ViewGroup) : RecyclerView.ViewHolder(containerView) {

    private val binding: GraphItemBinding
        get() = GraphItemBinding.bind(containerView)
    
    private val context = containerView.context
    
    fun configure(position: Int) {
        // グラフに表示する値
        val list = arrayListOf(30, 40, 50, 30, 20, 50, 60)
        // リスト内の数値を最大値をベースとした割合に換算
        val customList =
            list.map {
                ((it.toDouble() / (list.maxOrNull() ?: 0)) * 100).toInt()
            }
        // アニメーション後のグラフの高さ
        val graphValue = customList[position] * 20
        // 横幅を設定
        setWidth()
        // グラフアイテムの設定
        binding.graphItemView.run {
            // 背景色、コーナーなどの設定
            background = getBackgroundInfo()
            // アニメーション追加
            val heightAnimation = HeightAnimation(this, 0, graphValue)
            heightAnimation.duration = 2000
            this.animation = heightAnimation
            }
    }

    private fun setWidth() =
            (binding.graphItemLayout.layoutParams as ViewGroup.MarginLayoutParams).run {
                width = parent.measuredWidth / 7
            }

    private fun getBackgroundInfo(): Drawable {
        return GradientDrawable().apply {
            shape = GradientDrawable.RECTANGLE
            cornerRadius = context.resources.getDimension(R.dimen.corner_radius)
            setColor(ContextCompat.getColor(context, R.color.teal_200))
        }
    }
}

これで完成!!! f:id:r_sugiyama:20211209183446g:plain

最後に

今回作成したものをベースにカスタマイズしていけば、結構遊べると思います。
簡単な小ネタになってしまいましたが、記事を読んでいただきありがとうございました!

GoでRequest/Response BodyをロギングするHTTPサーバから見えてくるnet/http serverの実装

こんにちは。プラットフォームチームの池田です。

目だけは脱デジタルしようというきっかけでPodcastradiko、Audibleなどの音声コンテンツに最近ずっとハマっており、そのおかげでつよつよ家事マシンになりつつあります。

今回はゆるりとGoのnet/httpパッケージの内容を記事にします。

【目次】

やりたいこと

本記事にてやりたいことは以下2点です。

  • エラーレスポンス時にロギングとしてRequest/Response Bodyを標準出力するHTTPサーバを作りたい。
  • 上記に関連するGoの標準パッケージのnet/httpのserverな実装を追ってみたい。

弊社プラットフォームチームのGoなAPIサーバではGinフレームワークを採用していますが、今回はピュアなGoのnet/httpパッケージ内部を見てみることも目的としているのでミドルウェアを挟み込めるシンプルなHTTPルータを自作しサードパーティには依存しないようにします。

本記事で後述するロギングミドルウェアの実装方法はGinフレームワークでも同様になることを確認しています。

実行環境

$ go version
go version go1.17.2 darwin/amd64

準備 ~自作シンプルHTTPルータ~

上述の理由からシンプルなHTTPルータを作ります。

// シンプルHTTPルータ
type Router struct {
    pathFuncMap map[string]func(http.ResponseWriter, *http.Request)
    middlewares []func(next http.Handler) http.Handler
}

func (r *Router) HandleFunc(path string, f func(http.ResponseWriter, *http.Request)) {
    if r.pathFuncMap == nil {
        r.pathFuncMap = make(map[string]func(http.ResponseWriter, *http.Request))
    }
    r.pathFuncMap[path] = f
}

func (r *Router) AddMiddleware(mf func(handler http.Handler) http.Handler) {
    r.middlewares = append(r.middlewares, mf)
}

func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    // match path
    var handler http.Handler
    for p, f := range r.pathFuncMap {
        if p == req.URL.Path {
            handler = http.HandlerFunc(f)
        }
    }
    if handler == nil {
        handler = http.NotFoundHandler()
    } else {
        // set middleware
        for i := len(r.middlewares) - 1; i >= 0; i-- {
            handler = r.middlewares[i](handler)
        }
    }

    // serve http
    handler.ServeHTTP(w, req)
}

URLパスとアプリ用HTTPハンドラのペアをHandleFuncで、ミドルウェアAddMiddlewareで登録させます。リクエスト時にServeHTTP内でリクエストURLパスマッチ処理と登録されたミドルウェアを当て込んだハンドラ生成をします。

設定されるミドルウェアはスタックな後入れ先実行になり、一般的なフレームワークと同様に利用者は実装するミドルウェア上で次のハンドラーを呼び出すnext.ServeHTTPをする必要があります。

ミドルウェア設定無しで動かす

はじめに、ロギングしない場合でのサーバ実装と出力を示します。helloはRequest Bodyの中身に従って200 OKか400 Badを返すアプリ処理想定の単純なHTTPハンドラです。

// ロギング用ミドルウェア
func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        next.ServeHTTP(w, r)
    })
}
// アプリケーションHTTPサーバ
func hello(w http.ResponseWriter, r *http.Request) {
    b, _ := io.ReadAll(r.Body)
    if string(b) != `{"a": "b"}` {
        w.WriteHeader(http.StatusBadRequest)
        w.Write([]byte(`{"res": "No hello"}`))
        return
    }
    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`{"res": "hello"}`))
}

func main() {
    // 自作ルータをセット
    r := &Router{}
    r.HandleFunc("/hello", hello)
    r.AddMiddleware(LoggingMiddleware)

    http.ListenAndServe(":8090", r)
}

上記サーバを立ち上げリクエストすると以下のような動作になります。

$ curl -i -XPOST http://localhost:8090/hello -d '{"a": "b"}'
HTTP/1.1 200 OK
{"res": "hello"}

$ curl -i -XPOST http://localhost:8090/hello -d '{"a": "X"}'
HTTP/1.1 400 Bad Request
{"res": "No hello"}

このときサーバ側の標準出力にはまだ何も出てきません。

Request Bodyを出力する

Request Bodyをログとして出すようにしてみます。ハマりどころとして以下のように単純にやると処理がうまくいかなくなります。

失敗してしまう書き方

func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        bufOfRequestBody, _ := io.ReadAll(r.Body)

        var out io.Writer = os.Stdout
        fmt.Fprintln(out, "request body: ", string(bufOfRequestBody))

        // アプリケーション処理
        next.ServeHTTP(w, r)
    })
}
$ curl -i -XPOST http://localhost:8090/hello -d '{"a": "b"}' # 200 OKが返るべきリクエスト
HTTP/1.1 400 Bad Request

こうなってしまう原因はロギング後のアプリ側helloハンドラでの b, _ := io.ReadAll(r.Body) にて空の結果しか返らなくなってしまうためです。

空になる原因を深掘る

net/httpの標準ライブラリを追うとわかります。

http.Request.Bodyio.ReadCloserインターフェース型であり実態であるインスタンスnet/httptransfer.goにあるbody構造体です。今回関係するフィールドのみ抜粋すると以下になります。

ソースコードリンク

type body struct {
    src          io.Reader
    // ... 省略
    sawEOF     bool
    closed     bool
    // ... 省略
}

io.ReadAll(req.Body)にてhttp.Request.BodyRead関数が呼ばれることになりますが、そこを追うと以下のコードがあります。

ソースコードリンク

func (b *body) Read(p []byte) (n int, err error) {
    b.mu.Lock()
    defer b.mu.Unlock()
    if b.closed {
        return 0, ErrBodyReadAfterClose
    }
    return b.readLocked(p)
}

// このメソッドを呼ぶときはb.muをロックしておかなければならない。
func (b *body) readLocked(p []byte) (n int, err error) {
    if b.sawEOF { // ② 読み込み済みならば即EOFを返す
        return 0, io.EOF
    }
    n, err = b.src.Read(p)

    if err == io.EOF { 
        b.sawEOF = true // ① 読み込み終わったらbodyにあるsawEOFフラグをtrueにする
        // Chunked case. Read the trailer.
        if b.hdr != nil {
            if e := b.readTrailer(); e != nil {
// ... 省略

上記の①②の箇所のようにbody.sawEOFフラグにより読み取り済みかそうでないかを制御しておりこれにより2回目以降は空の結果しか返らないことがわかります。

なぜRequest Bodyを読み切る制御をしているのか深掘る

結論として、クライアントとのTCP接続にてコネクションを再利用するかしないかの判定材料の1つにRequest Bodyを読み切ったかがあるためです。

以下の箇所はレスポンスヘッダー書き込みをする処理であり、この処理時にRequest BodyがClosedにも関わらずsawEOFがfalseのままである場合は接続自体が不良という判断になり、net/httpでのHTTP Responseの実態であるresponse構造体のcloseAfterReplyをtrueにします。

ソースコードリンク

func (cw *chunkWriter) writeHeader(p []byte) {
// ... 省略
            case bdy.closed:
                if !bdy.sawEOF {
                    // Body was closed in handler with non-EOF error.
                    w.closeAfterReply = true
                }

そして以下のようにcloseAfterReplyはServe処理の最後でTCP接続を保ち続けるか切るかの判断に使われます。

ソースコードリンク

func (w *response) shouldReuseConnection() bool {
    if w.closeAfterReply {
        // The request or something set while executing the
        // handler indicated we shouldn't reuse this
        // connection.
        return false
    }
// ... 省略

※ net/http内のコメントアウトを読む限りこれはHTTP1.1の仕様由来のようですが私の方ではRFCドキュメント内でそのような記述は確認できませんした。

成功する書き方

正しい書き方として、以下のようにio.NopCloserで消費されてしまったRequest Bodyを補修する必要があります。下記のような処理はリクエスト内容を出力するnet/http/httputil でのDumpRequestなどでも同様に実装されています。

       bufOfRequestBody, _ := io.ReadAll(r.Body)

+       // [For Request Body] 消費されてしまったRequest Bodyを補修する
+       r.Body = io.NopCloser(bytes.NewBuffer(bufOfRequestBody))

        var out io.Writer = os.Stdout
        fmt.Fprintln(out, "request body: ", string(bufOfRequestBody))

Response Bodyを出力する

Response Bodyを出力する場合も少し工夫が必要になります。というのもhttp.ResponseWriterインターフェースにはResponse Bodyにアクセスするメソッドが無いためです。

以下のようにGoの構造体への埋め込み(Embedding)を用いてhttp.ResponseWriterインターフェースの実態をハック(intercept)します。

type interceptWriter struct {
    http.ResponseWriter
    status int
    body   *bytes.Buffer
}

// Writeをハックする
func (w *interceptWriter) Write(b []byte) (int, error) {
    w.body.Write(b)
    return w.ResponseWriter.Write(b) // 埋め込み元を呼ぶことで元の動作を保証する
}

// WriteHeaderをハックする
func (w *interceptWriter) WriteHeader(statusCode int) {
    w.status = statusCode
    w.ResponseWriter.WriteHeader(statusCode)
}

// ロギング用ミドルウェア
func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        bufOfRequestBody, _ := io.ReadAll(r.Body)
        // [For Request Body] 消費されてしまったRequest Bodyを修復する
        r.Body = io.NopCloser(bytes.NewBuffer(bufOfRequestBody))

        // [For Response Body] ResponseWriterへ割り込む (interceptする)
        iw := &interceptWriter{
            body:           bytes.NewBufferString(""),
            ResponseWriter: w, // 元のwriterインスタンスを埋め込む
        }
        w = iw

        // アプリケーション処理
        next.ServeHTTP(w, r)

        // レスポンスがエラーの場合、ログ出力する
        if iw.status >= 400 {
            var w io.Writer = os.Stdout
            fmt.Fprintln(w, "error status: ", iw.status)
            fmt.Fprintln(w, "request body: ", bytes.NewBuffer(bufOfRequestBody))
            fmt.Fprintln(w, "response body:", iw.body.String())
        }
    })
}

Go公式の以下にあるように埋め込み先の構造体で同名フィールドまたはメソッドを作った場合、そちらが優先される仕様になっています。

Embedding types introduces the problem of name conflicts but the rules to resolve them are simple. First, a field or method X hides any other item X in a more deeply nested part of the type.

埋め込み元としてのResponseWriterの実態はRequestの話にも出たresponse構造体です。以下リンクのWriteメソッドのコメントアウトの説明で最終的にコネクションのバッファに書き込まれる流れが記載されています。

ソースコードリンク

ログを確認する

ここまでのサーバを立ち上げ、エラーになるリクエストすると以下のようにサーバログ結果が確認できました。

クライアント側

curl -i -XPOST http://localhost:8090/hello -d '{"a": "X"}'
HTTP/1.1 400 Bad Request
{"res": "No hello"}

サーバ側ログ

error status:  400
request body:  {"a": "X"}
response body: {"res": "No hello"}

参考にさせていただいた記事

https://zenn.dev/imamura_sh/articles/retry-46aa586aeb5c3c28244e

https://tutuz-tech.hatenablog.com/entry/2020/03/23/162831

WebAIM の Contrast Checker を使いながら、コントラスト比を学ぶ

こんにちは。数ヶ月前からスキンフェードにはまっているネイティブアプリエンジニアの杉山です。
ぼかしを入れる作業の繊細さ、完成した時のグラデーションの美しさ、1週間程度で最高な状態を失ってしまう儚さ、全てが良い感じです。
今回は、「WebAIM」という Web サイトの Contrast Checker というツールを用いながら、コントラスト比の話をしたいと思います。

WebAIM とは

1999年に設立された非営利団体で、個人や組織が利用しやすいコンテンツを作成することで、障害を持つ人々への Web の可能性拡大をミッションとしています。

webaim.org

WebAIM の Contrast Checker を使ってみる

使い方は至って簡単で、Foreground Color と Background Color を入力するだけです。
これだけで、コントラスト比が適切かをチェックすることができます。
コントラス比が適切な場合は「Pass」と表示され、適切でない場合は「Fail」という表示が各項目に表示されます。
アプリなどで文字色指定や背景色指定する際に、試してみてください。

webaim.org

コントラスト比について

そもそも、コントラスト比とは 2色の輝度を使った比率のことを指します。
このコントラスト比に関しては、Web Content Accessibility Guidelines(WCAG)に Web で扱う際の適当な比率などが定義されています。

www.w3.org

コントラスト比がどのような計算式で求められているか見てみましょう。

(L1 + 0.05) / (L2 + 0.05)


L1 = 明るい色の相対輝度, L2 = 暗い色の相対輝度です。
相対輝度は、以下の式で求めることができます。

L = 0.2126 * R + 0.7152 * G + 0.0722 * B


RGBは、以下の式で求めることができます。

RsRGB <= 0.03928 then R = RsRGB/12.92 else R = ((RsRGB+0.055)/1.055) ^ 2.4
GsRGB <= 0.03928 then G = GsRGB/12.92 else G = ((GsRGB+0.055)/1.055) ^ 2.4
BsRGB <= 0.03928 then B = BsRGB/12.92 else B = ((BsRGB+0.055)/1.055) ^ 2.4

RsRGB = R8bit/255
GsRGB = G8bit/255
BsRGB = B8bit/255

値を当てはめて計算する際は、下から順にやっていくことで値を算出できます。
この値が WebAIM の Contrast Checker に表示されている Contrast Ratio です。

おまけ

上記の内容だけですとネイティブアプリエンジニアが記述する意味がない為、アプリに関することを少し記述します。
コントラスト比をお手軽に確認する手段として、Android ユーザーであれば「Accessibility Scanner」というアプリを使うのも良い方法です。
Android Studio Arctic Fox 以上であれば、開発環境上で不適切なコントラスト比の確認、修正が行えます。(修正に関しては、「Fix」ボタンを押すことで提案されたテキストカラーへ変更できます。)
・Accessibility のテストは、Espresso の 「AccessibilityChecks.enable()」を使用することで可能です。