おいしい健康 開発者ブログ

株式会社おいしい健康で働くエンジニア・デザイナーが社内の様子をお伝えします。

BigQueryで協調フィルタリングを使って使用食材が似たレシピを探す話

こんにちは。おいしい健康エンジニアの花井です。

今年の8月に入社しまして、iOS(クライアント)、API(サーバーサイド)、データ分析など幅広くやっています。 言語で言うと、Ruby(Ruby on Rails), Swift, Pythonですね。

今回は、食材が似たレシピを探す話、と題しておいしい健康のデータ分析の話をします。

概要

おいしい健康で最も人気がある下記レシピに対して、使用食材が似ているレシピを探します。 oishi-kenko.com BigQueryはクエリを気軽に実行して試行錯誤できるので最適化問題のソルバーとしてとても優秀です!

おいしい健康のデータ分析まわり

f:id:oishi-kenko:20181109142033p:plain

おいしい健康ではデータウェアハウスとしてBigQueryを使っています。

BigQueryへのデータコピーには、WebやアプリのログではFluentd, データベースに保存しているレシピや食品成分表等のデータではEmbulkを使っています。

データ可視化にはGoogle Data Studio*1を使っており、レポートやダッシュボードをサクッと作り社内会議で共有しています。 Google Data Studioでは表現できない複雑な図表を作成するときはGoogle Colaboratory*2を使っています。

使用食材が似ているレシピを探す

レシピには食材・調理手順・調理器具など様々な要素がありますが、今回は食材のみ着目します。

定義

食材(Ingredient)の集合を {I = (i_1, i_2, \cdots, i_n)}, レシピ(Recipe)の集合を {R = (r_1, r_2, \cdots, r_m)}と表記します。

レシピ {r_i}に対する食材 {j}の使用量を {w_{r_i,j}}と定義し、 レシピ {r_i}に対する食材使用率ベクトル {v_{r_i}}を次のように定義します。

\begin{align} \vec{v_{r_i}} = \frac{1}{\sum_{k=1}^n w_{r_i,k}} (w_{r_i,1}, w_{r_i,2}, \cdots, w_{r_i,n}) \end{align}

今回のデータ分析では食品成分表*3を利用し、調味料等を除外して食材の使用量を算出しています。

クエリでは、次のように実装できます。

協調フィルタリング

協調フィルタリングは、ユーザ間やアイテム間の類似性に基づいて推薦アイテムを決定する推薦アルゴリズムです。今回は類似性の指標としてコサイン類似度を採用し、設計します。

レシピ {r_i, r_j}に対するコサイン類似度 {s_{r_i r_j}}を次のように定義します。

\begin{align} s_{r_i r_j}= \frac{\sum_{k=1}^n v_{r_i,k} v_{r_j,k}}{\sqrt{\sum_{k=1}^n v_{r_i,k}^2} \sqrt{\sum_{k=1}^n v_{r_j,k}^2}} \end{align}

具体例を見てみましょう。

f:id:oishi-kenko:20181112185426p:plain

食材の集合 {I = (i_1, i_2, \cdots, i_n)}に対してそれぞれのレシピで使用してる食材は数種類なので、食材使用率ベクトルのほとんどの成分はゼロです。例にあげている2つのレシピに対する使用食材の成分だけ取り出すると次のようになります。

f:id:oishi-kenko:20181113114048p:plain

大雑把に言えば、この2つの図形がどのくらい一致するか、という指標がコサイン類似度です。 ベクトル同士の成す角度の近さを表現しており、1に近づくほど似ている、0に近づくほど似ていない、となります。

この例ではコサイン類似度は0.94958となり、よく似ていると判断できます。

まとめると、使用食材が似たレシピを探す話は、あるレシピ  {r_i}に対してコサイン類似度  {s_{r_i r_j}}が最大となるようなレシピ {r_j}を求める最適化問題となります。

\begin{align} arg\max_{r_j \in R} s_{r_i r_j} \end{align}

今回は、この最適化問題のソルバーとしてBigQueryを使い、総当たり*4で求めます。

BigQueryのメリット

Python等でも実装できますが、BigQueryはこんなメリットがあります。

  • 標準SQLが使えるので学習コストが安く、エンジニア間のコミュニケーションが容易。
  • メンテナンスコストも安い
  • 実行速度がとても速いため総当たりで解ける
  • Google製品と簡単に連携できて、結果出力やデータビジュアリゼーションの実行環境の構築コストが安い

コード

いよいよ実装に入ります。 BigQueryでは協調フィルタリングを次のようにシンプルに実装できます。

類似度1位を見てみよう

実は、コサイン類似度が最も大きいレシピはコサイン類似度で例を出したものでした。 oishi-kenko.com

しかし....

このレシピが最も似ているレシピだと納得できるでしょうか。

コサイン類似度が0.94958ですが、似ていると判断できるでしょうか。

「落とし揚げ」のレシピにはレタスも玉ねぎも生姜も食材に含まれていないです。

もう1度、食材使用率ベクトルを見てください。 f:id:oishi-kenko:20181113114048p:plain

豆腐と鶏ひき肉が多く含まれているレシピが選ばれていることが分かります。

つまり、食材使用率の低い食材が無視されてしまっています。

少量の食材でもレシピに含まれてほしいですね。

そこで、定義を変更します。

ingredient.total_gram / SUM(total_gram) OVER(PARTITION BY ingredient.recipe_id) AS gram_per_recipe

食材使用率に1を足し、下駄を履かせます。

少量の食材も考慮されるようにします。

1 + ingredient.total_gram / SUM(total_gram) OVER(PARTITION BY ingredient.recipe_id) AS gram_per_recipe

1位の結果を見てみましょう。 oishi-kenko.com コサイン類似度は0.80864です。

食材使用率ベクトルはこのようになりました。 f:id:oishi-kenko:20181113113904p:plain

5つの食材が被っているレシピが選ばれました。

レシピ類似度のデータビジュアリゼーション

以下はおまけです。 レシピ間の類似度ネットワーク*5を可視化してみます。

可視化対象

すべてのレシピ間を図示してしまうと辺だらけのネットワークになってしまうので、コサイン類似度が0.7以上のレシピ間を可視化します。

頂点 {V} : レシピ集合  {R = (r_1, r_2, \cdots, r_m)}

 {E} : { {(r_i, r_j) | s_{r_i r_j} \geqq 0.7 for \forall r_i, r_j \in R}}

データ取り出し

BigQueryのコンソール画面でGoogleスプレッドシートへ出力できる機能があるのですが、行数が多すぎるため使えません。 そんなときは、Google Colaboratoryが便利です。 下記のようにCSVファイルを簡単に取得できます。

可視化したネットワーク

ネットワーク可視化ソフトウェアプラットフォームCytoscape*6が便利です。

以下のフィルターを適用しました。

  • 頂点の次数*7が大きいほど大きくする描画する
  • 頂点の次数が大きいほど赤く、小さいほど青く描画する

巨大なネットワークと複数の小規模ネットワークができました。

f:id:oishi-kenko:20181112182528p:plain f:id:oishi-kenko:20181112182529p:plain

いくつかクラスターがあり、その中にハブとなるようなレシピが存在していることが分かります。

今回は可視化しただけですが、レシピのネットワーク構造を研究してサービスに活かせそうですね。

*1:https://datastudio.google.com/overview

*2:https://colab.research.google.com/

*3:おいしい健康では食品やレシピの栄養価は、主に文部科学省から食品の栄養価が書かれた一覧表(日本食品標準食品成分表 2015年版(七訂))を用いて算出しています。

*4:力まかせ探索、しらみつぶし探索とも言います

*5:数学用語ではグラフといいます。グラフG, 頂点集合V, 辺集合EのときG=(V,E)と表記します。この記事ではグラフではなくネットワークと書くことにします。

*6:バージョン3.7.0 https://cytoscape.org/

*7:頂点から出ている辺の数

Probot で GitHub の PullRequest のレビューアサイン, ブランチ削除を自動化する

Probot ご存知でしょうか!?

今回は、GitHub に便利な自動化ツールを追加できる Probot を弊社事例とともにお伝えしていきます。

Probot is 何

Probot は GitHub apps を Node.js により作れるフレームワークです。

probot.github.io

GitHubを使っているときに、「あぁ。。これ自動でなんとかしたい」と思うことはないでしょうか。

  • PullRequest(以下、PR) をマージしたら、ブランチを毎回手動で消すのめんどい
  • レビュワーを自動でアサインしてほしい
  • 最低一人に approve をしてもらわないと、マージできないルールで運用しているので、approve がないPRはマージボタンを無効にしたい

こういったことを実現するために自分で GitHub apps を書いたり、 APIでなんとかしようとしたりも出来ますが、そこまで時間的コストをかけられない。という方々も多いハズ。

Probot は GitHub Apps の開発フレームワークを提供していますが、更に Probot で開発された GitHub Apps の一覧も公開してくれており、既に公開されている GitHub Apps を使うことで、上記の様な願いはすぐに叶えられるようになっています 😇

おすすめ GitHub Apps by Probot

ここからは、弊社でも使っている便利な GitHub Apps をご紹介します。

Auto Assign

probot.github.io

PR を出すたびに指定した GitHub ユーザーの中から何名かをランダムに選択しレビューワーへのリクエストを飛ばす GitHub App です。

おいしい健康では、(ほぼ)すべてのPRを必ず他のエンジニアがコードレビューをします。

コードレビューでは、プログラムが設計通りにかけているかを始め、より良い解決方法はないか。などを中心にやり取りが行われます。在籍期間が長くなればなるほど、コードレビューを活発に行える一方で、新しく入ってきたばかりの人は、書かれたコードの前後背景がわからなかったり、コメントするポイントがわからなかったりして、レビューに参加しにくいことも珍しくありません。

そこで、この Auto Assign の登場です。

レビュワーはランダムに割り当てられるため、誰もが均等にレビューをする機会を得ることになります。

前後背景の知識を持っている人は、コードに考慮漏れがないかなどを中心に。 前後背景の知識を持っていない人は、今後前後背景の知識を蓄えられるように。この Auto Assign は役立っています。 もちろん、前後背景を知らない人たちばかりがアサインされてしまい、ちょっと不安だな。と思うときには、手動で新たにレビューワーを追加するなどの対応は必要になってきます。

なんにせよ、この GitHub Apps を入れてから、レビューが活発になったのは確かです。

Delete merged branch

probot.github.io

PR をマージしたら、マージ済みのブランチを自動で消してくれる GitHub App です。 マージ後のブランチは基本的に用済みであり、トラブルがあれば Restore もできるため、消してしまうのが弊社の通常運用になっています。

この GitHub App を導入するまでは定期的に消し忘れているものをチェックしたり、あまり消す習慣のない人にはリマインドを送ったりと、地味に手間な運用が行われていました。

すごく簡易な機能だけの GitHub App ですが、めちゃくちゃ役立っています。

おわりに

今回は便利 GitHub Apps の紹介がメインとなりましたが、自分で作るのもとても簡単なようです。(私はまだ作っていない) Probot を用いた自作 GitHub App の良い事例がでてきましたら、またお知らせします。

ペアプログラミングを導入して分かったこと

こんにちは。おいしい健康エンジニアの近藤です。

WWDC2018で他のエンジニアと情報交換させていただいた中で、他社ではペアプログラミングを導入して開発速度が2倍に上がったというお話を聞き、おいしい健康でも少しずつペアプログラミングを導入しています。今回はペアプログラミングを導入してみて分かったペアプログラミングのメリット、デメリットを紹介いたします。

f:id:oishi-kenko:20180817173215j:plain ※画像はペアプロの相方が出社するまでの間の場所取りとして活躍している人形です。

おいしい健康でのペアプログラミングのやり方

おいしい健康では、複雑なモデル周りの設計をするときや、比較的難易度の高いアプリの画面を実装するときに、ペアプログラミングをしながら最初に実装方針を決めています。
また、最近では、毎日エンジニア同士でランダムにペアを決めて、ペア同士は必ず隣に座るという運用も始めました。これによってフリーアドレスでもエンジニア同士で気軽に相談できるようにしました。こちらはペアプログラミングに限らず、テディベア効果も狙ったものになります。

ペアプログラミングのメリット

設計や実装のスピードが格段に上がる

ペアプログラミング中は途中で集中力が途切れたり、Slackを見たりする暇がなく、強制的にプログラミングが進むため、格段にスピードが上がります。

レビューによる手戻りが少ない

最初にペアプログラミングで実装方針を決めているため、大きな手戻りが発生することがありません。レビューでは細かい修正点だけを見ていけばよく、レビュー時間の短縮にも繋がります。

知識の底上げ、新メンバーのフォローアップ

新メンバーが新しく開発に入るタイミングでペアプログラミングをすることで、最初に詰まりやすいポイントに対してすぐにフォローすることができ、エンジニア全体の知識の底上げにも繋がります。

ペアプログラミングのデメリット

ナビゲータ役のエンジニアへの負担

弊社のペアプログラミングでは主にベテランエンジニアがナビゲータ、新メンバーがドライバーとして行っていることが多いため、ベテランエンジニアに負担が行ってしまうことがあります。こちらはペアプログラミングを続けていくことによって、エンジニア全体の知識が平準化されることで徐々にナビゲータ役のエンジニアが増えていけば解決されるかも知れません。

ペア席が取りづらい

おいしい健康ではフリーアドレスを導入しているため、外部ディスプレイ席を隣同士で2台確保しづらい場合があります。またフリーアドレスのメリットも少し薄れてしまいます。

単純作業の場合には向かない

比較的難易度の高い設計や実装の場合はペアプログラミングが効果を発揮しますが、ただ手を動かすだけの単純作業の場合は、一人で作業をした方が効率が良い場合があります。

さいごに

いくつかデメリットもありましたが、全体的にはペアプログラミングを導入したあとの方が実装スピードも上がり、エンジニア全体の知識の底上げにも繋がり良かったと思います。 開発速度が2倍とまでは行きませんでしたが、開発効率や品質向上の施策の一つとしてはオススメです。

参加してわかった WWDC ラボでの過ごしかた

はじめに

エンジニアの濵田です。昨年に引き続き、カリフォルニア州サンノゼで開かれた WWDC 2018 に参加してきました。

前回の WWDC 2017 参加で悔しかったことの一つに、「ラボ」と呼ばれるイベントにほとんど顔をださなかったことがあります。あとから、「ラボには絶対行ったほうがいい」という話をきいて、惜しいことしたなと残念でした。幸運にも再度 WWDC に参加することができたので、今回はラボ参加を優先して5日間を過ごしました 💪。

効率よくラボを楽しむにはいくつかコツがあることもわかったので、参加する上でのチップスや会場での流れを簡単にまとめます。

続きを読む

Linuxでプロセスごとに開いているファイルディスクリプタの数を調べる

こんにちは 山下(@tomorrowkey) です。

Linuxをセットアップする時にすぐに数を大きくするfile descriptorの数ですが、どのプロセスがどのくらいファイルを開いているか確認する方法を知らなかったので調べました。 早速最終的なコマンドから。例えばunicornが開いているファイルの数を調べるならこんな感じ。

for i in $(ps aux | grep "[u]nicorn" | awk '{print $2}'); do  ls /proc/$i/fd | wc -l; done

開いているファイルディスクリプタ/proc/$PID/fdにあるので、数を数えればOKです。
調べたことなど書きます。

dockerでfile descriptorの数を設定する

検証にはdockerを使います。 起動時に--ulimitというオプションを渡すだけで制限が可能です。 検証用なので極端に小さく設定します。

$ docker run --ulimit="nofile=64" --rm -it ruby:2.4.2-jessie /bin/bash
root@ea6e45c79d10:/# ulimit -n
64

irbを起動しただけのfile descriptorの数を調べる

プロセスIDを調べます。

# ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.1  20244  3208 pts/0    Ss   00:35   0:00 /bin/bash
root         7  0.1  0.1  20244  3196 pts/1    Ss   00:35   0:00 /bin/bash
root        14  1.6  0.4  47672  9632 pts/0    Sl+  00:35   0:00 irb
root        16  0.0  0.1  17500  2072 pts/1    R+   00:35   0:00 ps aux

file descriptorの数を調べます。

# ls /proc/14/fd | wc -l
9

9個でした。

今度はlsofで数を確認します。 rubyのdockerイメージにはlsofがインストールされていないので、インストールします。

# apt-get update && apt-get install -y lsof
# lsof -p 14
COMMAND PID USER   FD   TYPE DEVICE SIZE/OFF    NODE NAME
irb      14 root  cwd    DIR   0,46     4096   52302 /
irb      14 root  rtd    DIR   0,46     4096   52302 /
irb      14 root  txt    REG  254,1   146248 3145942 /usr/local/bin/ruby
irb      14 root  mem    REG  254,1   171800 1444564 /lib/x86_64-linux-gnu/libtinfo.so.5.9
irb      14 root  mem    REG  254,1   151120 1444515 /lib/x86_64-linux-gnu/libncurses.so.5.9
irb      14 root  mem    REG  254,1   297040 1444546 /lib/x86_64-linux-gnu/libreadline.so.6.3
irb      14 root  mem    REG  254,1   260648 3146915 /usr/local/lib/ruby/2.4.0/x86_64-linux/readline.so
irb      14 root  mem    REG  254,1   292976 3146919 /usr/local/lib/ruby/2.4.0/x86_64-linux/stringio.so
irb      14 root  mem    REG  254,1    33400 3146874 /usr/local/lib/ruby/2.4.0/x86_64-linux/enc/trans/transdb.so
irb      14 root  mem    REG  254,1   183168 3146831 /usr/local/lib/ruby/2.4.0/x86_64-linux/enc/encdb.so
irb      14 root  mem    REG  254,1  1738176 1444479 /lib/x86_64-linux-gnu/libc-2.19.so
irb      14 root  mem    REG  254,1  1051056 1444509 /lib/x86_64-linux-gnu/libm-2.19.so
irb      14 root  mem    REG  254,1    35176 1444487 /lib/x86_64-linux-gnu/libcrypt-2.19.so
irb      14 root  mem    REG  254,1    14664 1444492 /lib/x86_64-linux-gnu/libdl-2.19.so
irb      14 root  mem    REG  254,1   137384 1444543 /lib/x86_64-linux-gnu/libpthread-2.19.so
irb      14 root  mem    REG  254,1 15803280 3145980 /usr/local/lib/libruby.so.2.4.2
irb      14 root  mem    REG  254,1   140928 1444461 /lib/x86_64-linux-gnu/ld-2.19.so
irb      14 root    0u   CHR  136,0      0t0       3 /dev/pts/0
irb      14 root    1u   CHR  136,0      0t0       3 /dev/pts/0
irb      14 root    2u   CHR  136,0      0t0       3 /dev/pts/0
irb      14 root    3r  FIFO   0,10      0t0   54293 pipe
irb      14 root    4w  FIFO   0,10      0t0   54293 pipe
irb      14 root    5r  FIFO   0,10      0t0   54294 pipe
irb      14 root    6w  FIFO   0,10      0t0   54294 pipe
irb      14 root    7u   CHR  136,0      0t0       3 /dev/pts/0
irb      14 root    8u   CHR  136,0      0t0       3 /dev/pts/0

FDカラムに注目すると…

  • cwd -> current working directory
  • rtd -> root directory
  • txt -> program text
  • mem -> memory-mapped device
  • \[0-9]+r\ -> read-onlyで開いているファイル
  • \[0-9]+w\ -> write-onlyで開いているファイル
  • \[0-9]+u\ -> read-writeで開いてるファイル

lsofでfile descriptorの数を調べるにはFDが数字から始まるものを数えるとよさそう。 その他の値についてはこちら https://linux.die.net/man/8/lsof

lsofを使えばどのファイルを開いているかまで分かります。

たくさんのファイルを開いてfile descriporの数を調べる

スクリプトでファイルをたくさん開きます。

files = []
(1..(2 ** 10)).each do |i|
  puts i
  files << File.open("#{i}", "w")
  sleep 0.5
end
1
2
3
# ...
54
55
56
Errno::EMFILE: Too many open files @ rb_sysopen - 56
    from (irb):4:in `initialize'
  from (irb):4:in `open'
  from (irb):4:in `block in irb_binding'
    from (irb):2:in `each'
  from (irb):2
  from /usr/local/bin/irb:11:in `<main>'

無事ファイルの開き過ぎでエラーを吐きました。 irbをそのままに、違うターミナルでfile descriptorの数を調べます。

# for i in $(ps aux | grep "[i]rb" | awk '{print $2}'); do  ls /proc/$i/fd | wc -l; done
64

lsofで確認します。

# lsof -p $(ps aux | grep "[i]rb" | awk '{print $2}' | tr -d " ")
COMMAND PID USER   FD   TYPE DEVICE SIZE/OFF    NODE NAME
irb      14 root  cwd    DIR   0,46     4096   52302 /
irb      14 root  rtd    DIR   0,46     4096   52302 /
irb      14 root  txt    REG  254,1   146248 3145942 /usr/local/bin/ruby
irb      14 root  mem    REG  254,1   171800 1444564 /lib/x86_64-linux-gnu/libtinfo.so.5.9
irb      14 root  mem    REG  254,1   151120 1444515 /lib/x86_64-linux-gnu/libncurses.so.5.9
irb      14 root  mem    REG  254,1   297040 1444546 /lib/x86_64-linux-gnu/libreadline.so.6.3
irb      14 root  mem    REG  254,1   260648 3146915 /usr/local/lib/ruby/2.4.0/x86_64-linux/readline.so
irb      14 root  mem    REG  254,1   292976 3146919 /usr/local/lib/ruby/2.4.0/x86_64-linux/stringio.so
irb      14 root  mem    REG  254,1    33400 3146874 /usr/local/lib/ruby/2.4.0/x86_64-linux/enc/trans/transdb.so
irb      14 root  mem    REG  254,1   183168 3146831 /usr/local/lib/ruby/2.4.0/x86_64-linux/enc/encdb.so
irb      14 root  mem    REG  254,1  1738176 1444479 /lib/x86_64-linux-gnu/libc-2.19.so
irb      14 root  mem    REG  254,1  1051056 1444509 /lib/x86_64-linux-gnu/libm-2.19.so
irb      14 root  mem    REG  254,1    35176 1444487 /lib/x86_64-linux-gnu/libcrypt-2.19.so
irb      14 root  mem    REG  254,1    14664 1444492 /lib/x86_64-linux-gnu/libdl-2.19.so
irb      14 root  mem    REG  254,1   137384 1444543 /lib/x86_64-linux-gnu/libpthread-2.19.so
irb      14 root  mem    REG  254,1 15803280 3145980 /usr/local/lib/libruby.so.2.4.2
irb      14 root  mem    REG  254,1   140928 1444461 /lib/x86_64-linux-gnu/ld-2.19.so
irb      14 root    0u   CHR  136,0      0t0       3 /dev/pts/0
irb      14 root    1u   CHR  136,0      0t0       3 /dev/pts/0
irb      14 root    2u   CHR  136,0      0t0       3 /dev/pts/0
irb      14 root    3r  FIFO   0,10      0t0   54293 pipe
irb      14 root    4w  FIFO   0,10      0t0   54293 pipe
irb      14 root    5r  FIFO   0,10      0t0   54294 pipe
irb      14 root    6w  FIFO   0,10      0t0   54294 pipe
irb      14 root    7u   CHR  136,0      0t0       3 /dev/pts/0
irb      14 root    8u   CHR  136,0      0t0       3 /dev/pts/0
irb      14 root    9w   REG  254,1        0 3149355 /1
irb      14 root   10w   REG  254,1        0 3149365 /2
irb      14 root   11w   REG  254,1        0 3149369 /3
irb      14 root   12w   REG  254,1        0 3149372 /4
irb      14 root   13w   REG  254,1        0 3149422 /5
irb      14 root   14w   REG  254,1        0 3149423 /6
irb      14 root   15w   REG  254,1        0 3149425 /7
irb      14 root   16w   REG  254,1        0 3149455 /8
irb      14 root   17w   REG  254,1        0 3149457 /9
irb      14 root   18w   REG  254,1        0 3149458 /10
irb      14 root   19w   REG  254,1        0 3149459 /11
irb      14 root   20w   REG  254,1        0 3149460 /12
irb      14 root   21w   REG  254,1        0 3149461 /13
irb      14 root   22w   REG  254,1        0 3149462 /14
irb      14 root   23w   REG  254,1        0 3149463 /15
irb      14 root   24w   REG  254,1        0 3149464 /16
irb      14 root   25w   REG  254,1        0 3149465 /17
irb      14 root   26w   REG  254,1        0 3149466 /18
irb      14 root   27w   REG  254,1        0 3149467 /19
irb      14 root   28w   REG  254,1        0 3149468 /20
irb      14 root   29w   REG  254,1        0 3149469 /21
irb      14 root   30w   REG  254,1        0 3149470 /22
irb      14 root   31w   REG  254,1        0 3149471 /23
irb      14 root   32w   REG  254,1        0 3149472 /24
irb      14 root   33w   REG  254,1        0 3149473 /25
irb      14 root   34w   REG  254,1        0 3149474 /26
irb      14 root   35w   REG  254,1        0 3149475 /27
irb      14 root   36w   REG  254,1        0 3149476 /28
irb      14 root   37w   REG  254,1        0 3149477 /29
irb      14 root   38w   REG  254,1        0 3149478 /30
irb      14 root   39w   REG  254,1        0 3149479 /31
irb      14 root   40w   REG  254,1        0 3149480 /32
irb      14 root   41w   REG  254,1        0 3149481 /33
irb      14 root   42w   REG  254,1        0 3149482 /34
irb      14 root   43w   REG  254,1        0 3149483 /35
irb      14 root   44w   REG  254,1        0 3149484 /36
irb      14 root   45w   REG  254,1        0 3149485 /37
irb      14 root   46w   REG  254,1        0 3149486 /38
irb      14 root   47w   REG  254,1        0 3149487 /39
irb      14 root   48w   REG  254,1        0 3149488 /40
irb      14 root   49w   REG  254,1        0 3149489 /41
irb      14 root   50w   REG  254,1        0 3149490 /42
irb      14 root   51w   REG  254,1        0 3149491 /43
irb      14 root   52w   REG  254,1        0 3149492 /44
irb      14 root   53w   REG  254,1        0 3149493 /45
irb      14 root   54w   REG  254,1        0 3149494 /46
irb      14 root   55w   REG  254,1        0 3149495 /47
irb      14 root   56w   REG  254,1        0 3149496 /48
irb      14 root   57w   REG  254,1        0 3149497 /49
irb      14 root   58w   REG  254,1        0 3149498 /50
irb      14 root   59w   REG  254,1        0 3149499 /51
irb      14 root   60w   REG  254,1        0 3149500 /52
irb      14 root   61w   REG  254,1        0 3149501 /53
irb      14 root   62w   REG  254,1        0 3149502 /54
irb      14 root   63w   REG  254,1        0 3149503 /55

64個ファイルを開いていることが確認できました。

以上です。

Swift でドロップキャップな TextView を作る

こんにちは、関口@tanukiti1987 です。

今、おいしい健康では iOS アプリを開発しています。 そこで、ドロップキャップを使った表現が何箇所か登場する(予定)なのですが、 UITextView を純粋に使っただけでは、そういった表現が出来ずに、ちょっとした実装が必要になってきます。

今回は、そのちょっとした実装方法をご紹介していきます。

ドロップキャップ(drop-cap) とは

そもそも、ドロップキャップがなんなんだ。という話があると思います。 僕自身も実装を担当することになり、この言葉を知りました。

ドロップキャップは、こういうやつです。

f:id:oishi-kenko:20171115223923p:plain

1文字目が大きく表示され、以降1文字目を回り込むように文章が展開されていく例のやつです。

css の世界では

p:first-letter{
    font-size: 2em;
}

という指定をしてあげることで、このドロップキャップの表現が可能です。 いつもは嫌いだけど、この時ばかりは最高じゃないか、CSS

Swift ではどうやるのか

ここまで紹介しましたが、さきにも述べたように swift では :first-letter みたいなベンリ attribute はないので、自前でがんばっていくことになります。

使っていくのは、 UITextView UIImageView の2つです。

  • 一文字目を UIImageView としてコードで生成する
  • TextKit の exclusion path を指定することで 作った UIImageView の周りに文字を回り込ませる

この2点を行うことで、ドロップキャップを実現していきます。

文字を UIImageView として生成する

文字を画像として生成するには UIGraphics を使って、描画していきます。

今回は、以下のような関数を作ってみました。

@IBOutlet weak var dropCapImageView: UIImageView!

func createDropCapImage(text: String) -> UIImage {
    let size = CGSize(width: dropCapImageView.frame.size.width, height: dropCapImageView.frame.size.height)
    UIGraphicsBeginImageContext(size)

    let fontSize: UIFont = UIFont.systemFont(ofSize: 42.0)

    let style: NSMutableParagraphStyle = NSMutableParagraphStyle()
    style.alignment = NSTextAlignment.center
    style.lineBreakMode = NSLineBreakMode.byClipping
    style.minimumLineHeight = 30

    let attributes = [
        NSAttributedStringKey.font: fontSize,
        NSAttributedStringKey.paragraphStyle: style,
        NSAttributedStringKey.foregroundColor: UIColor.black,
        NSAttributedStringKey.backgroundColor: UIColor.clear
    ]

    text.draw(in: CGRect(x: 0, y: 0, width: size.width, height: size.height), withAttributes: attributes)
    let image: UIImage = UIGraphicsGetImageFromCurrentImageContext() as UIImage!
    UIGraphicsEndImageContext()

    return image
}

font 情報を諸々設定したあとで text.draw(in: CGRect(x: 0, y: 0, width: size.width, height: size.height), withAttributes: attributes) で文字を書き、 UIGraphicsGetImageFromCurrentImageContext() as UIImage! で画像を生成しています。

TextKit の exclusion path で文字を回り込ませる

先ほど作った画像を、UITextView の上に載せ、文字を回り込ませていきます。

こんな感じにviewを重ねます。

f:id:oishi-kenko:20171115230254p:plain

constraint などはよしなに調整してください。 今回のアプローチの少しイケてないところは、ドロップキャップの大きさを変えたり、テキストの attributes を変えると、レイアウトの微調整も必要になってくるところですね。。

ともあれ、他の方法もないので、この方法で突き進みます。

xibファイルが用意できたら、文字を回り込ませるような処理を書いていきます。

@IBOutlet weak var textView: UITextView!
@IBOutlet weak var dropCapImageView: UIImageView!
let text: String = "吾輩(わがはい)は猫である。名前はまだ無い。どこで生れたかとんと見当(けんとう)がつかぬ。"

textView.text = String(text[text.index(text.startIndex, offsetBy: 1)...])
setTextAttributes()

dropCapImageView.image = createDropCapImage(text: String(text[text.startIndex]))
let exclusionRect = CGRect(x: 0, y: 0, width: dropCapImageView.frame.size.width, height: dropCapImageView.frame.size.height)
let path: UIBezierPath = UIBezierPath(rect: exclusionRect)
textView.textContainer.exclusionPaths = [path]

また、ここで忘れてはいけないのは、1文字目は既に画像としてViewに含ませているので、2文字目以降の文字を UITextView に乗せていくというところです。

UITextView に2文字目以降の文字を代入し、 UIImageView に1文字目の画像を載せていきます。 最後に画像の周辺にテキストを回り込ませるための exclution path を生成し、 UITextView.textContainer.exclusionPaths に登録します。

ここまでいけば、完成です!

このあたりのコードを実装していけば、冒頭ご紹介したような

f:id:oishi-kenko:20171115232122p:plain

このような表示ができるはずです。

おしまいに

今回ご紹介したコードはこちらにおいておきました。

github.com

Xcode9.1 で作っていますので、古くて動かなくなったらご愛嬌ということで。 是非、みなさまのご参考になればと思います。

おしまいのおしまいに

おいしい健康では、一緒に Swift で iOS アプリを作ってくれる仲間を募集しています! 新しいプロジェクトであり、会社のコアプロダクトを作っているところですので、自由度も高く、もりもり開発していけます。

何より、この記事の内容を見て、思うところがある方とはぜひお話してみたいところです ( ̄ー ̄)

Swift初心者の関口がお送りしました〜。

Apache Solrでサジェスト機能を実装する

こんにちは。おいしい健康に入社して2ヶ月目の44akiです。

今回、おいしい健康のアプリで検索窓の入力サジェスト機能を導入する機会があり、 Apache SolrのSpellCheckComponentを使って実現しました。 おいしい健康のサービス全体でいえば小さな機能ではありますが、前職では検索エンジンベンダーでエンジニアをやっていたので、こういうところには無駄に拘ってしまいます。

この記事の内容は、Apache Solrのバージョン4.10.4で動作確認をしています。

f:id:oishi-kenko:20171107173455p:plain

候補語のデータについて

入力サジェスト機能を実現する場合、その候補語となるデータの取得元は大きく分けて2つあり、それぞれにメリット、デメリットがあります。

  1. 検索対象のデータから形態素解析で取得する
    検索対象のデータをmecab等の形態素解析エンジンで分かち書きして、候補語のデータとして使用する方法です。おいしい健康の場合はレシピ情報がこのデータにあたります。検索対象データを形態素解析して取得した候補語のため、必ず検索にヒットする候補語が取得できるというメリットがありますが、形態素解析エンジンの辞書データをもとに分かち書きされるため、辞書データの精度が低いと、候補語が名詞の途中で途切れたり、意図しない候補語が表示される可能性があります。

  2. 検索ログから取得する
    ユーザが実際に検索したキーワードをログとして保存しておき、候補語のデータとして使用する方法です。検索ログから候補語を取得しているため、ユーザーが実際の入力するようなキーワードをサジェストすることが出来ます。また、過去1ヶ月間の検索ログなど、集計対象の期間を絞ることで、キーワードのトレンドを反映し、秋ならサンマ、冬に近づくとサバを候補語の上位に表示するなどが実現できるようになります。ただし、検索ログベースのため、検索ログを取得する環境を構築する必要がある、検索にヒットしない候補語を除外する必要がある等、実装のコストは増えてしまいます。

今回は2の検索ログベースでサジェスト機能を実装しました。

schema.xml

先ずはApache Solrのスキーマを定義していきます。
候補語の表示用として name フィールドを定義します。

<fields>
    <field name="_version_" type="long" indexed="true" stored="true"/>
    <field name="id" type="string" multiValued="false" stored="true" indexed="true"/>
    <field name="name" type="string" multiValued="false" stored="true" indexed="true"/>
</fields>

また、検索用に text_ja_romaji の型を定義しています。 ローマ字に変換して検索を行うことで、入力途中のキーワードでもサジェストが行えるようにしています。

<fieldType name="text_ja_romaji" class="solr.TextField" positionIncrementGap="100" autoGeneratePhraseQueries="false">
  <analyzer>
    <tokenizer class="solr.JapaneseTokenizerFactory" mode="normal"/>
    <filter class="solr.CJKWidthFilterFactory"/>
    <filter class="solr.JapaneseReadingFormFilterFactory" useRomaji="true"/>
    <filter class="solr.ShingleFilterFactory" minShingleSize="2" maxShingleSize="99" outputUnigramsIfNoShingles="true" tokenSeparator=""/>
    <filter class="solr.LowerCaseFilterFactory"/>
  </analyzer>
</fieldType>

全体的な流れとしては、 JapaneseTokenizerFactory でKuromojiによる形態素解析を行い、 JapaneseReadingFormFilterFactory のオプションでuseRomajiを指定することで形態素解析で取得した読みをローマ字に変換します。最後に ShingleFilterFactory により形態素解析で分割された文字列を連結します。

ローマ字変換のイメージ>
おいしい健康→おいしい 健康→oishi kenko→oishikenko

solrconfig.xml

次にサジェスト用のsearchComponentとrequestHandlerを定義します。

今回はSolrの solr.SpellCheckComponent を使います。

<!-- Auto Complete component -->
<searchComponent class="solr.SpellCheckComponent" name="autocomplete_ja">
    <lst name="spellchecker">
      <str name="name">autocomplete_ja</str>
      <str name="classname">org.apache.solr.spelling.suggest.Suggester</str>
      <str name="lookupImpl">org.apache.solr.spelling.suggest.fst.AnalyzingLookupFactory</str>
      <str name="buildOnCommit">true</str>
      <str name="comparatorClass">score</str>
      <str name="field">name</str>
      <str name="suggestAnalyzerFieldType">text_ja_romaji</str>
      <bool name="exactMatchFirst">true</bool>
    </lst>
    <str name="queryAnalyzerFieldType">text_ja_romaji</str>
</searchComponent>

suggestAnalyzerFieldTypeとqueryAnalyzerFieldTypeに先程定義したtext_ja_romajiを指定することでローマ字での前方一致検索を行っています。

comparatorClassで候補語のソート順を指定します。score、freq、もしくは独自で作成したcomparatorClassが指定可能です。今回のように検索ログベースで、検索回数順にソートする場合は、通常はcomparatorClassを作成する必要がありますが、今回は前述のnameフィールドに「キーワード:検索回数」のようなフォーマットでインデックスし、アプリ側で取得したキーワードと検索回数をもとに検索回数順にソートすることで、簡易的に検索回数順ソートを実装しました。

最後に、上記のsearchComponentを「/autocomplete_ja」のパスで受けられるように、requestHandlerを定義します。

<!-- Auto Complete handler -->
<requestHandler name="/autocomplete_ja" class="org.apache.solr.handler.component.SearchHandler">
    <lst name="defaults">
      <str name="spellcheck">true</str>
      <str name="spellcheck.dictionary">autocomplete_ja</str>
      <str name="spellcheck.collate">true</str>
      <str name="spellcheck.count">100</str>
      <str name="spellcheck.onlyMorePopular">true</str>
    </lst>
    <arr name="components">
      <str>autocomplete_ja</str>
    </arr>
</requestHandler>

終わりに

今回はApache Solrの定義の部分のみの説明でしたが、実際には、Fluentd、BigQueryによる検索ログ取得、検索にヒットしない候補語の削除等も行いました。Apache SolrのSpellCheckComponentを使うことで比較的簡単に入力サジェストの実装を行うことができました。