コネヒト開発者ブログ

コネヒト開発者ブログ

Character filterを用いたアルファベットの大文字小文字対応によるゼロ件ヒット改善

皆さん,こんにちは!最近は検索エンジニアとしての仕事がメインの柏木(@asteriam)です.

直近は,検索基盤が整ってきたので,検索エンジンの精度改善の取り組みを行っています.その一環としてゼロ件ヒットの削減に努めていて,今回は「アルファベットの大文字小文字」に対応した話になります.

はじめに

改めて,今回は「検索クエリにおけるアルファベットの大文字小文字に依らない検索結果」を出すための取り組みとして,Character filterを用いて検索クエリを正規化することでこの問題に対処した内容になります.

今回は以下の内容を紹介していこうと思います.

  • 取り組みの背景と課題
  • Character filterを用いた正規化
  • 対応後のゼロ件ヒットの推移

※ 検索エンジンとしてAmazon OpenSearch Serviceを活用しています.これはElasticsearchから派生したOSSの検索エンジンであるOpenSearchのマネージドサービスになります.


目次


取り組みの背景と課題

背景

私たちのチームでは検索結果の改善を行うためにゼロ件ヒットのログを収集しています.僕はそれを毎日眺めているのですが,その中でドキュメントとして存在してそうなワードが含まれているのに何故かゼロ件ヒットとして検索されている回数が多いものがあったため,調査したのが始まりです.

実際のゼロ件ヒットワードの一例ですが,「hpvワクチン」というワードがありました.小文字では検索結果が0件でしたが,これを大文字の「HPVワクチン」で検索するとドキュメントが数十件と返ってくることを確認しました.

このように幾つかのワードで,アルファベットの大文字小文字の違いによるゼロ件ヒットを確認し,中にはゼロ件ヒットワードとして,2~3週間で数百件以上検索されているものもあったので,この問題の解決に当たろうと思いました.

課題

課題としては明確で,アルファベットの大文字と小文字の違いだけで検索結果が変わってしまいユーザーの検索体験がマイナスになっている ということです.そもそもドキュメントが存在しない場合と違って,僕たちのドメインでは普通に使われうるワードでゼロ件ヒットしてしまうことは,検索に対する期待も下がってしまいますし,アプリからの離脱も発生します.

また,この問題は登録しているユーザー辞書による影響もあり,以前からユーザー辞書の整理は必要だと考えられていたため,そこをまとめて整理する良い機会でもありました.

Character filterを用いた正規化

前提理解

この問題を解決するためには,ElasticsearchにおけるAnalyzerの処理の流れを理解する必要があります.

  1. 0個以上Character filters
  2. 1個かつ必須Tokenizer
  3. 0個以上Token filters

参考:Anatomy of an analyzer

こちらの図がわかりやすいので,引用(Elasticsearch のアナライザをカスタマイズする)させて貰います.

引用:Elasticsearch のアナライザをカスタマイズする

上図では,それぞれどのような動きをしているかというと,

  1. HTML要素を除去するCharacter filter
  2. 空白で分割するTokenizer
  3. 大文字を小文字に変換するToken filter

となっています.

ここで,ユーザー辞書が適用されるのは2番目のTokenizerを行う部分になります.我々が元々設定していたAnalyzerの定義では,Character filterとしてicu_normalizerのみを適用してました.Token filterはTokenizerの後に適用されるため,ユーザー辞書に基づいたトークナイズが優先され,アルファベットの大文字ワードをToken filterで小文字化しても別ワードとして処理される状況でした.このためシノニム辞書を用意していても別ワードとして処理されます(大文字と小文字は別物になってしまいます).

対応方法

この解決方法としては2種類あると思います.

  1. Character filterを使ってアルファベットの大文字を小文字にする(Character filterによる正規化)
  2. API側で検索エンジンに入れる前にアルファベットの大文字を小文字にする

そして,辞書に登録するワードは全て小文字で登録する.

今回は,検索されたワードは検索エンジン側で処理が全て完結するようにしたかったため,1番目の方法で対応することにしました.

処理の追加自体はとても簡単で,下のサンプルコードのように,char_filterに新しくフィルターを追加し,そのmappingsに愚直に大文字と小文字の変換を入れるだけになります.

# sample
{
  "settings": {
    "analysis": {
      "analyzer": {
        "my_analyzer": {
          "tokenizer": "standard",
          "char_filter": [
            "alphabet_mappings_filter"
          ]
        }
      },
      "char_filter": {
        "alphabet_mappings_filter": {
          "type": "mapping",
          "mappings": [
            "A => a",
            "B => b",
            "C => c",
            "D => d",
            "E => e",
            "F => f",
            "G => g",
            "H => h",
            "I => i",
            "J => j",
            "K => k",
            "L => l",
            "M => m",
            "N => n",
            "O => o",
            "P => p",
            "Q => q",
            "R => r",
            "S => s",
            "T => t",
            "U => u",
            "V => v",
            "W => w",
            "X => x",
            "Y => y",
            "Z => z"
          ]
        }
      }
    }
  }
}

あとは,ユーザー辞書とシノニム辞書に登録されているアルファベットを全て小文字に変更することで,大文字と小文字の区別なく検索結果として同じ結果が返ってくるようになります.

1つ注意事項として,今回のようにAnalyzerの定義が変更されて,それに伴って辞書の更新が入る場合は都度インデックスの再作成が必要になるので注意が必要です.

事象としては,辞書が変更されたことにより,元々インデックス化されていたトークンとの整合性が取れずに何もヒットしない状態でした.改めてインデックスを作り替えることで,トークンが正しい状態でインデックス化された状態になりヒットするようになります.

対応後のゼロ件ヒット率の推移

ゼロ件ヒット率の推移を見てみると,下図から今回の対応前後で数値が減少していることがわかります.実際の割合で言うと,減少率は1%以下ではあるものの,検索数ベースだと数百件は減っていることになります.

ここで,ゼロ件ヒット率=全検索数のうち検索結果が0件であった検索数の割合と定義しています.

ゼロ件ヒット率

今回の施策では,他の「検索利用率・1訪問あたり平均検索回数」といったメトリクスには影響は見られませんでした.

そもそもゼロ件ヒットの検索数は全検索数のうち数%しかないため,改善できる幅はそれほど大きくなく,減少率で言うと大きな改善にはなっていませんが,ゼロ件ヒットの上位ワードは今回の対応で改善できたので,よりニーズのあるワードへの対策は実施できたのと,ドキュメントが存在しているのにヒットしないのはユーザー体験にも大きく影響するので,実施したことに価値はあったと考えています.

おわりに

今回はCharacter filterを用いてアルファベットの大文字小文字対応(Character filterによる正規化)を行いました.その結果としてゼロ件ヒットの削減に貢献できました!

今回の改善でも実感しましたが,トークナイズに影響を与える辞書整備は重要で,その整備がまだまだ十分行えていないので,ガッと進めて行きたいと思っています.

また,捨て仮名/中点対応,ひらがな/漢字対応なども今後進めて行く予定です!

千里の道も一歩からということでゼロ件ヒット削減に向けて,今回の取り組みや辞書更新,今後の取り組みを通して,小さな積み重ねでユーザー体験の向上目指して改善して行きます!

最後に,コネヒトではプロダクトを成長させたいMLエンジニアを募集しています!!(切実に募集しています!)
もっと話を聞いてみたい方や,少しでも興味を持たれた方は,ぜひ一度カジュアルにお話させてもらえると嬉しいです.(僕宛@asteriamにTwitterDM経由でご連絡いただいてもOKです!)

www.wantedly.com

また,コネヒトにおける機械学習関連業務の紹介資料も公開していますので,こちらも是非見て下さい!!

tech.connehito.com

参考

ドラッカー風エクササイズをやってみました!

こんにちは。コネヒトに8月に入社し、サーバーサイドエンジニアをしている高橋です。

今回は私自身のチームジョインや、更なる新メンバーのジョインもあったので、「お互いを理解し期待をすり合わせる」ことを目的に「ドラッカー風エクササイズ」を開催しました! オンラインでも開催できるように今回はmiroを使って実施したので、ご興味ある方はぜひご覧ください。

目的・背景

  • メンバーのスキルや経験、価値観、期待値などをお互いに理解し、期待をすり合わせる

→背景は新メンバージョインに伴いチーム人数が多くなったので、チーム内でお互いの得意なことや期待値を把握し、理解促進を図りたいと思ったからです。

ドラッカー風エクササイズとは

ドラッカー風エクササイズとは、アジャイルサムライの著者Jonathan Rasmusson(ジョナサン・ラスマセン)が名付けたチームビルディングのことです。 4つの質問に全員が答えることで、相互理解の促進と期待の擦り合わせという効果があります。

ワークの進め方

今回は9人で実施をしたので進め方を工夫しました。

  • 事前にワーク内容を伝え、自己開示パートの3つの質問はある程度考えてきてもらう
  • タイムマネジメントをしっかりと行う

今回は90分の枠をとって実施をし、以下のような流れで行いました。

  1. 導入 (5分)
  2. ワーク内容の説明 (5分)
  3. チェックイン (2分)
  4. ワーク (60分)

    自己開示パート 以下の3つの質問に回答してもらい、本人から説明していただきます。

    • 自分は何が得意か
    • 自分はどういう風に仕事をするか
    • 自分が大切に思う価値は何か

    各質問を最大3つに絞ることで自分の中でも特に大切にしている価値観が整理することができます。

    期待値パート

    • 自分がメンバーから期待されていると思うこと この質問は先入観が生まれないよう紹介はせずに以下の回答をそれぞれが他のメンバーに書いていいきます。

    • 自分以外のメンバーがその人に期待していること その後ファシリテーターから上記2点を紹介し、なぜこの人にこう書いたか、どういった背景で書いたのか、など深掘りを一人ずつしていきます。

    • ファシリテーターが一人ずつ発表

  5. 感想一人一言 (10分)

また今回急に参加が難しくなってしまったメンバーに対しては、事前に付箋を記入してもらい、他のメンバー同様に期待値なども欠席者のところに貼るようしました。そして後日ワークの動画を共有しました。 ワークの様子を動画で正確に残せるのはオンライン開催の良いところですね。

注意点

目的は期待をすり合わせることになるので、ワークをする上で以下の2点留意しましょう!

  • メンバーを否定しない
  • 期待されていることは鵜呑みにしない→絶対にやらなくてはいけないということではない

私は過去にドラッカー風エクササイズを行い、自分が思う期待値とメンバーからの期待値に違いがあり少しマイナスな印象を抱いてしまったので、あくまで意見として捉えることが大事です。

感想

メンバーのことを知れるというのはもちろんですが、自分の価値観を整理できるいい機会になったと強く感じることができました! 私は一児の母ということもあり、メンバーからの期待してることに「当事者ならではの視点」という付箋が多く自分がどう期待されているか具体的に知ることができました。 また「遠慮しないでもいいよ」というような付箋もあり、もっとこうしていいんだというこれからの動き方にもいい変化が出てきそうと感じました。

改善点

  • 期待値が現状行っていることの延長線上が多い
    • こうしたらよかったんじゃないか?みたいな課題に対するアクションもセットで書けると良さそう
  • 自己開示パートの時間が少し短かったので、メンバーの価値観を知り切れなかった
  • 自己開示パートは事前記入しておいてもらった方がスムーズだったかも
  • チームとして何を目指すかなどが話せなかったこと

メンバーからの感想も一部挙げておきます。

  • 自分で思いつかなかったポイントで期待されている側面は学びになりました!
  • 改めて自分が大事にしていることってなんだっけ?を見つめ直すいい機会になりました!
  • 各メンバーがそれぞれ、自分に対してもしくは他人に対して感じるものを言語化したことで、自己認識・他者認識の再構築ができて良かった。
  • 新メンバー参画の際にまたやりたい!
  • 定期的にやりたい

まとめ

今回のドラッカー風エクササイズを通じてメンバーからはポジティブな意見が多く、開催してよかったなと思っています。人の価値観、モチベーションなどは変化していくものでもあると思うので、定期的にチームビルディングを開催していきたいです。 すごく良いチームであるのでもっともっとお互いに刺激し合えるチームになっていけたらと思っています。

過去のチームビルディングのブログ記事↓ tech.connehito.com

tech.connehito.com

コネヒトでは一緒に働く仲間を募集しています! そして興味持っていただけた方は気軽にご連絡ください! https://www.wantedly.com/companies/connehito/projects

「スマイル制度」を利用してBabelのスポンサーになりました

こんにちは、エンジニアの富田です! 今回は社内制度を利用して、Babelのスポンサーをした事例を紹介したいと思います。

Babelが資金難であることを知った

時は遡ること1年前ですが、当時以下の記事からBabelが資金難で困っていることを知りました。

www.publickey1.jp

コネヒトのフロントエンド開発でもBabelを使っているため、なんとか支援できないかなと考えてみたものの、「会社での支援って大変そう?」という思いから具体的なアクションに結びつけられませんでした。

支援したいという気持ちの再燃

何もできないまま時は流れていましたが、たまたまトヨクモさんのOSSに関する支援活動を知って、素晴らしい活動をされていると共感しました。

oss.toyokumo.co.jp

上記をきっかけになんとか小さく始められないかと模索し見つけたのが、コネヒトの「スマイル制度」です。

スマイル制度を使って支援

コネヒトには「スマイル制度」という制度があります。これは開発組織のインプットとアウトプットの活性化を促進する制度です。とてもコネヒトらしい制度になっているので、詳しくはスマイル制度をご覧ください。

tech-vision.connehito.com

上記の制度を活用して少額ではありますが、Babelスポンサーをさせていただきました!

github.com

最後に

1年越しに支援できたことを嬉しく思うのと同時に、コネヒトは他にも様々なOSSを利用しているので、私たちが使っているOSSに関して支援できることがないか引き続き考えていきたいと思います。

Xcode14対応

こんにちは!コネヒトでiOSエンジニアをやっていますyanamuraです。

ママリのiOSアプリでXcode14でビルドが通るように対応を行いました。そんなに大変ではありませんでしたが、多少修正するところがあったのでまとめてみました。

やったこと

ライブラリのアップデート

ママリではRealmを使っていて、バージョンを上げる必要がありました。RealmSwiftを10.28.1、RxRealmを5.0.4にアップデートしました。

また、Xcode14にするとSwiftのバージョンが5.7になります。ママリではswift-formatを使っていて、こちらのバージョンを0.50700.1にアップデートしました。

ソースコードの修正

以下のようなエラーがコンパイル時に出るようになったので修正を行いました。

The compiler is unable to type-check this expression in reasonable time: try breaking up the expression into distinct sub-expressions

コンパイルエラーが発生した箇所はcombilneLatestでネストしている部分でした。

このようにcombineLatestの数の制限を突破するためにネストしている箇所が!

Observable.combineLatest(
    Observable.combineLatest(
      x,
      y
    ),
    a,
    b,
    c,
    …
    )
)

このようにネストしている箇所を外に出してやることでエラーは解消できました。

let xy = Observable.combineLatest(
      x,
      y
)

Observable.combineLatest(
    xy,
    a,
    b,
    c,
    …
    )
)

archiveに失敗する問題対応

上記対応でビルドはできるようになったのですが、archiveで失敗するようになりました。

CocoaPodsで追加しているライブラリで署名のエラーが出ました。

Signing for “XXX” requires a development team. Select a development team in the Signing & Capabilities editor

CocoaPodsのほうでもissueとしてあがっていました。 https://github.com/CocoaPods/CocoaPods/issues/11402

解決方法としてはこちらのようにTeam IDを設定するか https://github.com/CocoaPods/CocoaPods/issues/11402#issuecomment-1149585364

post_install do |installer|
  installer.generated_projects.each do |project|
    project.targets.each do |target|
        target.build_configurations.each do |config|
            config.build_settings["DEVELOPMENT_TEAM"] = " Your Team ID  "
         end
    end
  end
end

CODE_SIGNING_ALLOWEDをNOにすることで解決できました。 https://github.com/CocoaPods/CocoaPods/issues/11402#issuecomment-1201464693

post_install do |installer|
  installer.pods_project.targets.each do |target|
    if target.respond_to?(:product_type) and target.product_type == "com.apple.product-type.bundle"
      target.build_configurations.each do |config|
          config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO'
      end
    end
  end
end

まとめ

Xcode14にあげることで、iOS16の実機デバッグができるようになりますし、Swift5.7の新機能が使えるようになります。

Swift5.7ですぐに使いたいのがif letなどの短縮で

var startDate: Date?

if let startDate = startDate {
…

と書いていたのが以下でよくなりますね!

var startDate: Date?

if let startDate {
…

Swift 5.7については公式のだとSwift language announcements from WWDC22 が見やすい感じになっていますのでXcode14対応が終わったら見てみると良いかなと思います。

例年春頃にはXcode14にあげないとAppStoreに申請できなくなってしまうのでお気をつけください。


コネヒトでの開発に興味を持っていただいた方はカジュアルにお話しましょう〜 TwitterのDMなどでも大丈夫ですのでお気軽にどうぞ www.wantedly.com

3年ぶりにCTO Night & Dayに参加してきました!

こんにちは。CPOをやっている@itoshoです。

先日AWSさん主催のCTO Night & Day 2022 in 長崎(以下、CTO Night)に参加させていただきましたので、簡単にレポートをお届けします。*1

長崎とCTO Nightと私

CTO Nightとは技術の立場から経営に参画するリーダーが一堂に会する招待制カンファレンスですが、今年は3年ぶりにオフサイト開催となりました。僕自身も前回参加させていただいたのが、3年前の京都開催のCTO Nightでしたので、久しぶりのオフサイト開催は"くる"ものがありました。*2

CTO Night and Day 2019 | AWS Startup ブログ

長崎については、僕は初上陸で観光はほとんど出来なかったのですが、随所に異国情緒を感じさせる建造物があったり、レトロな路面電車が走っていたりと歴史を多層的に感じられる素敵な街でした。また、ワークショップで軍艦島ツアーに参加させていただいたのですが、悪天候*3で残念ながら上陸できなかったため、再チャレンジするためにもまた長崎を訪ねたいなと思いました。

長崎駅前の様子荒波から臨む軍艦島CTO Night参加の証
長崎の思い出

偶発性の高いセッション

クローズドな内容もあるため詳細を語ることは出来ませんが、今回も非常に学びのあるセッションが数多くありました。例えば、Diversity, Equity & Inclusionについてはコネヒトの事業柄、より意識して行動していく必要があると感じました。

Web3に関しても様々な議論が巻き起こっていますが、Web3の真のビッグウエーブが来たとき"沖"に立っていられるよう、一人の技術者として要素技術をしっかり理解しておく必要性があると感じたので、手始めにDAppの個人開発をはじめてみようと思いました。

また、CTO Nightのような大規模なカンファレンスのセッションは一方向のコミュニケーションになりがちですが、今回のCTO Nightでは感想戦が用意されていたり、セッション自体もオーディエンスを巻き込んだりしていたのが印象的で、ネットワーキングの時間を含めて偶発的なコミュニケーション機会が多く、対面の良さを十二分に感じることが出来ました。

僕も有り難いことに登壇の機会をいただいたのですが*4、そこでも短時間ではあるものの参加者の方と対話をすることが出来たので、非常に"お土産"の多い3日間となりました。

ちなみに、僕の登壇の様子はAWSの畑さんのレポートにチラッと掲載していただいております。

note.com

3年ぶりの参加で感じたこと

今回、本当に多くのCTOの方が参加しており、良い意味でCTOという役割がコモディティ化しているなと感じました。その中で飽くなき挑戦を繰り返している参加者の方からは良い刺激をたくさんいただきました。一方で、僕も3年ほどCTOを務めて、コミュニティへ還元できる部分も出来てきたので、改めてCTOないしは技術コミュニティを盛り上げていくぞ!という気持ちを新たにしました。*5

というわけで、コネヒトはTech Visionでも「技術コミュニティになくてはならない開発組織をつくる」という方針を掲げているので、他社やコミュニティの方々と手を組んだ形でのイベントも一緒に出来ればと考えています。一緒にやってもいいぞ!という方や少しでも興味のある方はTwitterFacebookまでお気軽にご連絡いただければと思います。

最後に

最高の場を用意してくださったAWSさん、本当にありがとうございました!

DAY1の集合写真(撮影:漆原未代さん)
DAY1の集合写真(撮影:漆原未代さん)

*1:現職はCPOですので、ex-CTOとして参加させていただきました。

*2:もちろん、感染対策は丁寧に施されており、安心して参加することが出来ました。

*3:船の揺れと水しぶきと寒さが本当にヤバかったので、2022年トップクラスの"ハードシングス"でした。

*4:余談ですが、前回の京都開催に引き続きチャペルでの登壇となりました。

*5:個人的には今回改めてSaaS系スタートアップの盛り上がりを感じたので、争うわけではありませんが、toC系のインターネット企業も盛り上げていきたいです。

コネヒトは DroidKaigi 2022 に協賛します!

こんにちは!Android アプリエンジニアの富田 です。

本日は、Android アプリ開発者の祭典 DroidKaigi 2022 に協賛するお知らせです。

コネヒトは DroidKaigi 2022 に協賛いたします!

DroidKaigi 2022に、サポータースポンサーとして協賛いたします。

droidkaigi.jp

スポンサーするにあたって、コネヒトは「人の生活になくてはならないものをつくる」というミッションを掲げているので、技術コミュニティについても同様に、サポートして一緒に盛り上げていくことができたら、と思っております。

イベント概要

  • 日時 2022年10月5日(水)〜10月7日(金)
  • 場所 東京ドームシティ プリズムホール (Day 1, Day 2)、ベルサール飯田橋ファースト (Day 3) および YouTube
  • 主催 DroidKaigi 実行委員会
  • 公式HP https://droidkaigi.jp/2022/
  • タイムテーブル https://droidkaigi.jp/2022/timetable

今回はオフラインでの参加も可能ということで、盛り上がりそうですね!

個人的に楽しみなセッション

全体的に Jetpack Compose のセッションが多い印象で、特に以下の 3 セッションが気になっているのでチェックしていきます!

最後に

みなさん楽しんでいきましょ〜!

コネヒトでは Android エンジニアを積極採用中です!

hrmos.co

オンボーディング改善に機械学習を活用する〜Graph Embedding(node2vec)による推薦アイテム計算〜

みなさんこんにちは。MLチームのたかぱい(@takapy0210)です。

本日は、コネヒトの運営するママリのオンボーディング改善に機械学習を活用した事例のパート2をお話をしようと思います。

パート1については以下エントリをご覧ください(取り組んだ背景なども以下のブログに記載しています)

tech.connehito.com

(おさらい)
今回実施しているオンボーディング改善には大きく分けて以下2つのステップがあります。
ステップ1:興味選択にどのようなトピックを掲示したら良いか?(前回のブログ参照)
ステップ2:興味選択したトピックに関連するアイテムをどのように計算(推薦)するか?

本エントリでは主にステップ2の内容についてお話しできればと思います。
(※本記事で添付している画像に関しては、開発環境のデータとなっています)


目次


はじめに

前回の記事で触れたように、2022年09月時点では以下のようなトピックがオンボーディングで表示され、ユーザーの好みを取得しています。

オンボーディングで表示されるトピック選択画面

ここで選択されたトピックに対して、どのようにしてアイテムを推薦すれば良いでしょうか?

まず最初に考えられるのはルールベースによる推薦だと思います。

ルールベースの推薦

一般的に、機械学習をプロダクトへ導入する際、まずはシンプルなベースラインを作成してそこから徐々に改善していく、というフローを踏むと良いと言われています。

今回も例に漏れず、まずはルールベースのアプローチでベースラインを作成しました。

このルールベースによるアプローチでは機械学習は一切使わず、オンボーディング時に選択したトピックに対して、そのトピックが付与されているアイテム(質問のこと。以降アイテム = 質問として記載します)を新着順に推薦する、というものです。

例えば、「つわり」を選択したユーザーに対しては、「つわり」タグが付与されているアイテムを新しい順に推薦します。

以下にあるように、ママリでは各アイテムごとに紐づくタグデータを保持しています。このタグは正規表現で付与されているため、アイテム本文に該当文字列がある場合に付与されます

ルールベースの課題

上記画像の文章を見ていただくとわかると思うのですが、このアイテムの主題は「保育園」ではなく「仕事」です。例えばこのアイテムが「保育園」に興味のあるユーザーに推薦された場合、ユーザー体験はあまり良くないと考えられます。

このように、単純なルールベースでアイテムを推薦すると、ユーザーが期待しているアイテムとは異なるアイテムが推薦される可能性があり、これが1つの課題となっていました。

これを改善すべく機械学習を用いたアプローチの検証をしていきました。

機械学習を用いたアプローチ

今回は、各タグのEmbeddingが計算できればタグ同士の類似度を計算することができ、そこからタグとアイテムとの類似性も良いものが計算できるのではないか、という仮説のもと、Graph Embedding(後述)を用いて実験していきました。

Embeddingは、レコメンデーションをはじめとして活用できる幅が広いというのも採用理由の1つです。
以下のブログではEmbeddingの様々なメリットが述べられています。

blog.twitter.com

Graph Embeddingとは

Graph Embeddingとはグラフをベクトル空間に落とし込む手法のことで、大きく以下の2つに分けられます

  • ノード埋め込み
  • グラフ埋め込み

詳しく知りたい方は以下の記事が参考になると思います。

towardsdatascience.com

今回はnode2vecというアプローチを用いて、前述した「タグ」の埋め込み表現を計算していきます。
参考にした論文は以下になります。

arxiv.org

node2vecの概略

今回の手法では、大きく分けて以下のステップでノードのベクトルを計算します。

  1. グラフ上をランダムウォークし、シークエンスデータを生成する
  2. 生成したシークエンスデータを学習データとして、教師なし学習を行う
  3. 学習した結果からノードのベクトルを取得する

ざっくり以下のようなイメージです。

https://towardsdatascience.com/graph-embeddings-the-summary-cc6075aba007 より

本論文のオリジナルな部分はステップ1の部分で、「どのようにランダムウォークしてデータをサンプリングするか」という部分にあります。

詳細はGMOさんの記事が分かりやすいので、是非こちらをご覧いただければと思います。

recruit.gmo.jp

node2vecの実装

実際にPythonを用いて実装していきます。

使用データ

今回使用したデータは以下のような形式になっています

  • id:アイテムID
  • tag_id:タグID
  • tag:タグの名称

1つのアイテムIDに複数のタグが紐づいているイメージです

1. グラフ上をランダムウォークし、シークエンスデータを生成する

まずはNetworkXを用いてグラフを生成します。

今回は前述した「タグ」をノードとしてグラフを生成していきます。同じアイテムに紐づくタグがある場合は、それらのノードをエッジで接続してグラフを生成していきます。

ただ、関連性の薄い(自己相互情報量が少ない)タグ同士についてはグラフに追加しないように調整しています。

def create_tag_graph(input_df: pd.DataFrame) -> Any:
    """タググラフの構築
    エッジの重みは、2つのタグ間の点ごとの相互情報に基づいており、次のように計算されます
        log(xy) - log(x) - log(y) + log(D)
            xy は、タグ x とタグ y の両方が付与されているアイテムの数
            x は、タグ x が付与されているアイテムの数
            y は、タグ y が付与されているアイテムの数
            D は、タグの総数
    """

    # Step1: タグ間の重み付けされたエッジを作成する
    pair_frequency = defaultdict(int)
    item_frequency = defaultdict(int)
    tags_grouped_by_qid = list(input_df.groupby("id"))

    for group in tqdm(tags_grouped_by_qid, position=0, leave=True, dynamic_ncols=True, desc="Compute tag frequencies"):
        current_tags = list(group[1]["tag"])
        for i in range(len(current_tags)):
            item_frequency[current_tags[i]] += 1
            for j in range(i + 1, len(current_tags)):
                x = min(current_tags[i], current_tags[j])
                y = max(current_tags[i], current_tags[j])
                pair_frequency[(x, y)] += 1

    # Step2: ノードとエッジを含むグラフを作成する
    D = math.log(sum(item_frequency.values()))
    tags_graph = nx.Graph()

    # タグ間に加重エッジを追加する
    for pair in tqdm(pair_frequency, position=0, leave=True, dynamic_ncols=True, desc="Creating the tag graph"):
        x, y = pair  # タグの組み合わせを取得
        xy_frequency = pair_frequency[pair]  # 2つのタグの組み合わせが両方付与されたアイテム数
        x_frequency = item_frequency[x]  # タグ x を参照しているアイテムの数
        y_frequency = item_frequency[y]  # タグ y を参照しているアイテムの数

        # 自己相互情報量の計算
        pmi = math.log(xy_frequency) - math.log(x_frequency) - math.log(y_frequency) + D
        weight = pmi * xy_frequency  # エッジの重みを設定

        # 関係性の薄いタグのエッジは追加しない
        if weight >= 10:
            tags_graph.add_edge(x, y, weight=weight)

    return tags_graph

# グラフの作成
tag_graph = create_tag_graph(input_df=df[['id', 'tag']])

print(f"Total number of graph nodes: {tag_graph.number_of_nodes()}")
print(f"Total number of graph edges: {tag_graph.number_of_edges()}")
>> Total number of graph nodes: 7276
>> Total number of graph edges: 312634

生成されるグラフは以下のようなイメージです

生成されるタググラフイメージ

次にこのグラフを、前述したnode2vecで提案された手法でランダムウォークし、シークエンスデータを生成します。

def next_step(graph: Any, previous: str, current: str, p: int, q: int) -> str:
    """ランダムウォークで次に進むノードを選択する
    """

    neighbors = list(graph.neighbors(current))  # 近傍ノード
    weights = []  # 重み
    # pとqを基準にして、近傍へのエッジの重みを調整する
    for neighbor in neighbors:
        if neighbor == previous:
            # 前のノードに戻る確率
            weights.append(graph[current][neighbor]["weight"] / p)
        elif graph.has_edge(neighbor, previous):
            # ローカルノードを訪問する確率
            weights.append(graph[current][neighbor]["weight"])
        else:
            # 確率をコントロールして前に進む確率
            weights.append(graph[current][neighbor]["weight"] / q)

    # それぞれのノードを訪問する確率を計算する
    weight_sum = sum(weights)
    probabilities = [weight / weight_sum for weight in weights]

    # 訪問するノードを確率的に選択する
    next = np.random.choice(neighbors, size=1, p=probabilities)[0]
    return next

def random_walk(graph: Any, num_walks: int, num_steps: int, p: int, q: int) -> list:
    """グラフをランダムウォークし時系列データを取得する
    """

    walks = []
    nodes = list(graph.nodes())

    for walk_iteration in range(num_walks):

        # ランダムに最初のノードを決定するためにシャッフル
        random.shuffle(nodes)
        for node in tqdm(nodes, position=0, leave=True, dynamic_ncols=True,
                         desc=f"Random walks iteration {walk_iteration + 1} of {num_walks}"):
            # ノードを選んで歩行を開始
            walk = [node]

            # num_stepsの間、ランダムに進む
            while len(walk) < num_steps:
                current = walk[-1]
                previous = walk[-2] if len(walk) > 1 else None

                # 次に訪問するノードを計算する
                next = next_step(graph, previous, current, p, q)
                walk.append(next)

            walks.append(walk)

    return walks

# ランダムウォークを使って時系列データを生成する
tag_series = random_walk(graph=tag_graph, num_walks=10, num_steps=10, p=2, q=3)

ここで生成されるデータは以下のようなリストとなっています。

node2vecで生成されるデータイメージ

ニュアンスの似ているタグが近傍に存在していることが定性的に見て分かると思います。

2. 教師なし学習でノードの情報をベクトル化する

今回はgensimを用いて、自然言語処理ではおなじみのskip-gramという手法でベクトル化していきます。

tag_embedding_model = Word2Vec(
    tag_series,
    vector_size=100,
    window=3,
    hs=1,
    min_count=1,
    sg=1,
    workers=multiprocessing.cpu_count(),
    seed=42
)

定性的にチェックしてみる

ここまででタグのベクトルが計算できたので、類似タグを見ながらモデルの良し悪しを定性的にチェックしてみます。

つわり

ベビーグッズ

練馬区

生後1ヶ月

定性的には良さそうなベクトルが計算できていそうです。

ランダムシークエンスとnode2vecの比較

ランダムにシークエンスデータを生成した場合と、node2vecの手法でシークエンスデータを生成した場合にできるモデルにどのくらい違いがあるのか?という部分についても簡単に触れておこうと思います。

同じデータを使用し、アイテムに紐づくタグをそのままリストに変換します。(これでランダムシークエンスデータが生成できる)

sequence_df = pd.DataFrame(df.groupby(['id'])['tag'].apply(list)).reset_index()
sequence_df['tag_length'] = sequence_df['tag'].apply(lambda x: len(x))

# タグの数が3個未満のデータは除外する
sequence_df = sequence_df[sequence_df['tag_length'] > 3].reset_index(drop=True)
tag_series = sequence_df['tag'].tolist()

先ほど同様、リスト形式のデータを生成しました。

ここで生成されたデータは以下のようになっています。

ランダムに作成したシークエンスデータ例

このデータを同じようにskip-gramモデルで学習させて、モデルの定性チェックをしてみます。

tag_embedding_model = Word2Vec(
    tag_series,
    vector_size=100,
    window=3,
    hs=1,
    min_count=1,
    sg=1,
    workers=multiprocessing.cpu_count(),
    seed=42
)

左がnode2vecのシークエンスデータで学習させたもの、右がランダムシークエンスデータで学習させたものになります。

つわり

ベビーグッズ

練馬区

生後1ヶ月

「つわり」や「ベビーグッズ」に関してはそこまで差分がないですが、「練馬区」や「生後1ヶ月」といったタグに関しては、大きな差分が見られます。

今回はskip-gramというアルゴリズムを利用している性質上、シークエンスデータで見た時に周辺にくる単語が似ているものであれば、類似度が高くなる傾向にあります。

例えば、ランダムシークエンスデータで生成した「練馬区」ベクトルに関しては、東京都内の市や区が類似タグとして計算されていますが、ここで計算されてほしいのは「練馬区に関連するタグ」なので、node2vecの方が良いベクトルを計算できていることが分かります。(桜台マタニティクリニック / 久保田産婦人科病院 / 練馬病院 はどれも練馬区にあるクリニックであり、大塚産婦人科は練馬区からちょっとだけ離れた場所にあるクリニックです)

ママリで投稿されるデータには以下のようなものも多く、そのままアイテムに紐づくタグを用いてデータを生成すると、どうしても地理的に近くの区や市が類似タグとして計算される傾向にあります。
このようなことが起こる可能性を減らすためにも、今回はnode2vecを採用しました。

タグとアイテムの類似度算出

最後に、タグとアイテムの類似度を計算し、オンボーディングで選択した興味トピックに対して、どのアイテムを推薦するかを算出します。

アイテムのベクトル計算にはSWEMを利用し、アイテムに紐づくタグベクトルから、アイテムのベクトルを算出しました。

これらを用いて、タグベクトルとアイテムベクトルのコサイン類似度を計算し、オンボーディングで選択した興味トピックと類似しているであろうタグが付与されているアイテムを推薦するようにしました。

例えば、2022年09月現在「つわり」を選択したユーザーに対しては以下のようなアイテムが推薦されます。

「つわり」を興味選択したユーザーに推薦するアイテム例

ここではサラッと「タグとアイテムの類似度を計算して推薦しています」と書いていますが、実際はPdMとデータを泥臭く見ながらパラメータの調整などをしていきました。

最終的には以下のようなスプレッドシートが数枚できあがり、どのパラメータで生成されたアイテムが良いのだろうか、というのを定性的にチェックしていきました。

どのトピックを選ぶとどんなアイテムが推薦されるのか?を泥臭くチェックしている様子

結果はどうだった?

抽象的な数値になってしまいますが、アプリインストール初日ユーザーのアイテムクリック系の指標が、ルールベースと比較して1.5倍ほど向上しました 🎉

現在は機械学習のロジックを全ユーザーに適用し運用しています。

最後に

オンボーディング改善の内容は、PyCon 2022でも詳細をお話する予定なので、興味がある方は是非観にきてください! (登壇日時は10月14日(金)の17時10分〜17時40分に決まりました!)

2022.pycon.jp

We Are Hiring !!

コネヒトでは一緒に働く仲間を募集しています!

www.wantedly.com

機械学習に関しては、過去の取り組み事例などを以下にまとめていますので、是非見てみてください!

tech.connehito.com

そして興味持っていただけた方はカジュアルにお話しましょう! (TwitterのDMでもMeety経由でも、気軽にご連絡ください)