コネヒト開発者ブログ

コネヒト開発者ブログ

コネヒトは 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経由でも、気軽にご連絡ください)

AWS Step Functionsに新たに14個追加された関数をいくつか使ってみた

こんにちは、インフラエンジニアのささしゅう(@sasashuuu)です。 本日は最近アップデートされ、新たに14個追加されたStep Functionsの組み込み関数について、いくつか活用事例を交えてご紹介したいと思います。

アップデートの概要

Step Functionsの組み込み関数アップデートに関するアナウンスは以下になります。 aws.amazon.com

また、組み込み関数のドキュメントは以下になりますが、日本語版のドキュメントにはまだアップデートされた関数の情報の記載が見当たらなかったため、英語版の方を貼っておきます。 docs.aws.amazon.com

配列操作、UUIDの作成、JSONオブジェクトの結合など計14個の組み込み関数のアップデートがありました。これらの組み込み関数はASL(Amazon States Language)というStep Functionsのリソースを定義するのに使用するJSONベースの構造化言語に直接追加されているため、そのまますぐにでもStep Functionsから利用することができます。

アップデートされた組み込み関数の種類や挙動に関してはクラスメソッド様が以下の記事で網羅的に取り上げられているので、こちらが参考になります。 dev.classmethod.jp

活用事例

ここからは、具体的な活用事例をご紹介します。 今回ご紹介するのは以下の5つです。

States.UUID

まずは1つめの States.UUID です。これは、v4 UUIDの乱数を返してくれる関数です。

活用事例としては、ワークフロー上で作成するAuroraクラスターとそれに紐づくインスタンスについて、既存リソースの識別子の命名をベースにしたいが、サフィックスとしてランダムな識別子を入れて被らないように命名したいというケースで活用しました。

次のようなイメージです。

  • 既に存在するリソースの命名(Auroraクラスターの識別子)
    • dev-hoge-aurora-cluster
  • あたらしく作りたいリソースの命名(Auroraクラスターの識別子)
    • dev-hoge-aurora-cluster-{ランダムな識別子}

ワークフロー実行時の入力です。

{
    "HogeDbClusterIdentifier": {
        "Identifier": "dev-hoge-aurora-cluster-{}"
    }
}

Pass ステートで States.UUID を使用して、生成した識別子を元の入力を上書きする形で更新します。

    "Pass": {
        "Type": "Pass",
        "Next": "Foo",
        "Parameters": {
            "Identifier.$": "States.Format($.HogeDbClusterIdentifier.Identifier,States.UUID())"
        },
        "ResultPath": "$.HogeDbClusterIdentifier"
    }

Auroraクラスターを作成します。(下記は RestoreDBClusterFromSnapshot を用いたスナップショットからの復元の例です。)

    "RestoreDBClusterFromSnapshot": {
        "Type": "Task",
        "Next": "Pass",
        "Parameters": {
            "DbClusterIdentifier.$": "$.HogeDbClusterIdentifier.Identifier",
            "Engine": "aurora",
            "SnapshotIdentifier": "hoge"
        },
        "Resource": "arn:aws:states:::aws-sdk:rds:restoreDBClusterFromSnapshot"
    }

結果、次のようなUUIDがサフィックスとしてついた識別子のクラスタが作成されます。(※インスタンス作成のステップはここでは割愛。)

dev-hoge-aurora-cluster-a7cae12b-3e92-4c7a-b2f5-07416d023dbc

States.StringSplit

続いては States.StringSplit です。これは、文字列を区切り文字で値の配列に変換してくれる関数です。 何かのパラメータを複数設定したい際などに便利で、活用事例としては、RDSのクラスター作成時のSGなどの指定で利用しました。

ワークフロー実行時の入力です。

{
    "SecurityGroups": "sg-01xxxxxx,sg-02xxxxxx"
}

続いてスナップショットからクラスターを復元する RestoreDBClusterFromSnapshot ステートの定義です。

    "RestoreDBClusterFromSnapshot": {
        "Type": "Task",
        "Parameters": {
          ...
          "VpcSecurityGroupIds.$": "States.StringSplit($.SecurityGroups, ',')",
                  ...
        },
        "Resource": "arn:aws:states:::aws-sdk:rds:restoreDBClusterFromSnapshot",
        "Next": "CreateDBInstance",
        "Catch": [
            {
                "ErrorEquals": [
                    "States.ALL"
                ],
                "Next": "NotifySlackFailureTheOthers"
            }
        ],
        "ResultPath": "$.Hoge"
    },

入力で渡していた SecurityGroupsStates.StringSplit の関数の第1引数に、区切り文字としてカンマを第2引数に与えることにより、API実行時のパラメータの指定を次のように展開してくれます。

    {
        ...
        "parameters": {
          ...
          "VpcSecurityGroupIds": [
                "sg-01xxxxxx",
                "sg-02xxxxxx"
            ],
          ...
        },
        ...
    }

States.MathAdd、States.ArrayLength、States.ArrayGetItem

最後にこの3つの関数の活用事例をまとめて紹介します。 ざっくり関数の性質を説明すると次のような内容です。

  • States.MathAdd
    • 引数に与えた2つの数値の合計値を返す。
  • States.ArrayLength
    • 引数に与えた配列の長さを返す。
  • States.ArrayGetItem
    • 引数に与えた配列とインデックスから対象の値を返す。

この3つの関数は一連のフローで組み合わせて次のようなケースで活用しました。

  1. ワークフローで実行するAPIのresponseのoutputが配列で返る
  2. States.ArrayLength で配列の長さを取得する
  3. 配列データをループ処理にかける
  4. States.ArrayGetItem で対象の配列とCounterとして用意していたパラメータを使用し、インデックスを指定して要素を取り出す
  5. 取り出した要素が処理の対象の要素かどうかを Choice ステートで判定する
  6. 対象の要素一致していなければCounterとして用意していたパラメータを States.MathAdd でインクリメントする、インクリメントが配列の長さに達している場合は処理をループ処理を終了する
  7. 再びループの先頭に戻る
  8. 繰り返し…

実際に簡略化して再現したものが次のようなものになります。

{
    "Comment": "A description of my state machine",
    "StartAt": "DescribeDBClusters",
    "States": {
        "DescribeDBClusters": {
            "Type": "Task",
            "Next": "Pass",
            "Parameters": {},
            "Resource": "arn:aws:states:::aws-sdk:rds:describeDBClusters",
            "ResultPath": "$.Output",
            "ResultSelector": {
                "Length.$": "States.ArrayLength($.DbClusters)",
                "DbClusters.$": "$.DbClusters"
            }
        },
        "Pass": {
            "Type": "Pass",
            "Next": "Choice",
            "Parameters": {
                "DbClustersLength.$": "States.MathAdd($.Output.Length, -1)",
                "DbCluster.$": "States.ArrayGetItem($.Output.DbClusters, $.Counter.Val)"
            },
            "ResultPath": "$.Tmp"
        },
        "Choice": {
            "Type": "Choice",
            "Choices": [
                {
                    "Variable": "$.Tmp.DbCluster.DbClusterIdentifier",
                    "StringMatches": "dev-hoge-aurora-*",
                    "Next": "Pass (2)"
                }
            ],
            "Default": "Pass (1)"
        },
        "Pass (1)": {
            "Type": "Pass",
            "Next": "Choice (1)",
            "ResultPath": "$.Counter",
            "Parameters": {
                "Val.$": "States.MathAdd($.Counter.Val, 1)"
            }
        },
        "Choice (1)": {
            "Type": "Choice",
            "Choices": [
                {
                    "Variable": "$.Tmp.DbClustersLength",
                    "NumericEqualsPath": "$.Counter.Val",
                    "Next": "Fail"
                }
            ],
            "Default": "Pass"
        },
        "Fail": {
            "Type": "Fail"
        },
        "Pass (2)": {
            "Type": "Pass",
            "End": true
        }
    }
}

DescribeDBClusters で全てのクラスターを取得し、先述したようなフローの処理を行なっています。

ワークフロー実行時の入力です。

{
    "Counter": {
      "Val": 0
    }
}

1つずつステートの処理を見ていきます。

最初の DescribeDBClusters ステートでは States.ArrayLength を使用し、 Output.Length のパスで取得したデータの長さを取得、Output.DbClusters のパスで取得したデータを丸ごとを取得し、元の入力と合わせて次のステートに引き継いでいます。

    "DescribeDBClusters": {
        "Type": "Task",
        "Next": "Pass",
        "Parameters": {},
        "Resource": "arn:aws:states:::aws-sdk:rds:describeDBClusters",
        "ResultPath": "$.Output",
        "ResultSelector": {
            "Length.$": "States.ArrayLength($.DbClusters)",
            "DbClusters.$": "$.DbClusters"
        }
    }

次の Pass ステートでは、States.MathAdd を使用し、Tmp.DbClustersLength のパスで本来の配列データから1を引いた数を取得、States.ArrayGetItem を使用し、Tmp.DbCluster のパスで対象のクラスター情報を取得し、次のステートに引き継いでいます。

    "Pass": {
        "Type": "Pass",
        "Next": "Choice",
        "Parameters": {
            "DbClustersLength.$": "States.MathAdd($.Output.Length, -1)",
            "DbCluster.$": "States.ArrayGetItem($.Output.DbClusters, $.Counter.Val)"
        },
        "ResultPath": "$.Tmp"
    }

さらに Choice ステートでは ループ上で絞り込んで取得している対象が dev-hoge-aurora-* というクラスターの識別子と一致しているかをチェックしています。

    "Choice": {
        "Type": "Choice",
        "Choices": [
            {
                "Variable": "$.Tmp.DbCluster.DbClusterIdentifier",
                "StringMatches": "dev-hoge-aurora-*",
                "Next": "Pass (2)"
            }
        ],
        "Default": "Pass (1)"
    }

一致していなかった際は Pass (1) へ移り、用意していた入力のCounterをインクリメントします。States.MathAdd を使い数値の1を追加しています。

    "Pass (1)": {
        "Type": "Pass",
        "Next": "Choice (1)",
        "ResultPath": "$.Counter",
        "Parameters": {
            "Val.$": "States.MathAdd($.Counter.Val, 1)"
        }
    }

もし、ここでインクリメントしていたCounterの数値が、取得していた配列データの数に達した場合は Choice (1) の判定により、処理が終了するようになっています。

    "Choice (1)": {
        "Type": "Choice",
        "Choices": [
            {
                "Variable": "$.Tmp.DbClustersLength",
                "NumericEqualsPath": "$.Counter.Val",
                "Next": "Fail"
            }
        ],
        "Default": "Pass"
    }

以上のように配列の操作系の組み込み関数も強化されているので組み合わせて柔軟に利用することができます。

余談ですが、ループ系の処理に関しては Map ステートという入力配列の要素ごとに一連のステップを実行してくれるものがあるのですが、ループ途中のBreakやそこからループ内の任意のoutputをもとの入力に統合するような実装などが難しいように感じ、今回は上記の組み込み関数を使用し、配列のループ操作を行いました。

おわりに

Step Functionsの組み込み関数が大幅にアップデートされ、ちょっとした計算処理や整形処理などがLambdaなどのリソースを使わなくてもワークフロー上で行えるようになり、非常に便利になりました。みなさんも機会があればぜひ積極的に活用することをおすすめします。

検索システムで再現率向上に取り組んだ話

こんにちは。エンジニアの永井(shnagai)です。

今回は、現在進めている検索システム内製化プロジェクトの中で、検索エンジニアとしてはほぼ未経験に近い自分が半年ほど試行錯誤した内容の一部を書き記していこうと思います。

※筆者の経験としては、Elasticsearch✕kibanaのログ基盤は複数構築経験はありで、Elasticsearch周りの設定への知識は0ではないレベル

この記事を見て、検索に詳しい方や自分もやってみたいという方がいたら是非お声がけいただけるとうれしいです。

内容は、ざっくり下記の構成になっています。

  • 作りながら身にしみた検索システムの奥深さ
  • 初回ABテストでは既存エンジンに惜敗。再現率向上を目指すためのチューニング
  • これから

作りながら身にしみた検索システムの奥深さ

プロジェクトの開始前に、そもそも検索システムを自社で作り運用していけるのかの当たりをつけるために、ママリのデータを使った検索システムのモック作りを開始しました。この時の自分は、暗に「1つの検索クエリに対していかに正確な検索結果を返すか」を解くべき課題と設定し、下記にあるように検索ワードに対して間違いの少ない検索システムを目指してモック作りを進めました。

社内向けのモックを作る際の最初の方針

  • 出来るだけ検索ノイズが少なくなるように
    • OpenSearchの match_phrase クエリを使って検索ワードの順序を厳格に判定
    • kuromojiはnormalモードを利用 ※searchで意図せぬトークン分割されるのを防ぐため
    • 家族ノートという別プロダクトを開発するときに作ったユーザ辞書をカスタム辞書として利用

※検索エンジンとしては、OpenSearchを採用しているのでクエリ等はOpenSearch(Elasticsearch7.10.2相当)のもの

その後、モックが出来上がり社内定性チェックを行い、そのFBを読み解く中で、下記のようなインサイトを得て少しずつ意識が変わってきました。

「検索クエリは検索者の検索意図を必ずしも表したものではない」

「検索クエリぴったりのものでなくても検索意図に合う結果であればよいパターンもある」

「0件ヒットの体験は検索システムとしてはかなり悪い」 ※コンテンツがそもそもなければもちろん許容

つまり、検索クエリは検索者の目的を必ずしも正しく反映したものではなく(自分的には目からウロコでした)、検索クエリからいかに意図を汲み取り次のアクションにつながる結果を返せるかが検索システムの役目ということをだんだんと理解し始めました。

そんな中、「検索システム」という良書をチームメンバが見つけ、検索システム内製化チームで輪読会をはじめました。この本は、めちゃくちゃ良書で今自分たちがぶつかっている課題等が見事に言語化されており毎度チームメンバとうなりつつ検索システムに対する理解を深めている最中です。

特に、「ルックアップベースの検索モデル」には課題があり、「クエリや検索行動」自体を理解する QueryUnderstandingが検索システムを作る上では欠かせないと解説されていて、めちゃくちゃうまく言語化されており自分が抱えていた課題感が腑に落ちたのを覚えています。 何回も読み直しており、めちゃくちゃオススメの良書です。

検索システム ― 実務者のための開発改善ガイドブック(電子書籍のみ) – 技術書出版と販売のラムダノート

初回ABテストでは既存エンジンに惜敗。再現率向上を目指すためのチューニング

「ルックアップベースの検索モデル」は、自分の中では出来るだけ間違いの少ない検索システムと捉えており、その方針で作った検索システムで最初のABテストに望みました。

結果、KPIに設定していた検索CTRの有意差判定で既存検索システムに負けてしまいました。

当初の検索システムは間違いを少なくするために適合率高めに設定していたのですが、検索ログから出した数値やユーザからのFBから再現率が低すぎる傾向が読み取れました。

適合率と再現率については、下記のelastic社のブログが詳しいのでこちらをご参照ください。

How to implement Japanese full-text search in Elasticsearch

www.elastic.co

さて、再現率向上のためのアプローチですが、ここではわかりやすい2つのチューニングについて説明していきます。

※一般的というよりはコネヒトの環境独自のチューニングの観点が大きいのでその点はご了承ください。

カスタム辞書のチューニング

ユーザからのお問い合わせベースで調査していると、地名を含めた検索で意図した検索結果になっていないことが見えてきました。

  • 地域名を入れた検索クエリで、検索結果が返ってこないケースがたまにある
    • 例)「厚木」では「厚木市」のドキュメントが当たらない

これは、別プロダクトで作ったカスタム辞書で市区町村が細かく定義されており、それをそのままKuromojiのカスタム辞書として利用していたことが原因でした。

わかりやすく、具体例で説明すると

厚木市の保育園 という文章をKuromojiで形態素解析すると

  • 【本来の形】 厚木/市/の/保育園 と分割され、厚木市 厚木 どちらでも検索にヒットする
  • 【ABテスト時点】 厚木市 というカスタム辞書が優先されるので、 厚木市/の/保育園 と分割されてしまう。結果、 厚木 という検索ワードでは厚木市の保育園 というドキュメントがヒットしない状況になっていました。

元のカスタム辞書は、BigQueryに独自の転置インデックスを登録するために作った辞書で、細かい単語を定義することに価値があったのですが、OpenSearchのKuromojiの辞書としてそのまま転用すると思わぬ落とし穴があったなという所感です。

地名系をカスタム辞書から一通り除外して、例のような検索クエリにドキュメントが返ることを確認しました。

Kuromojiのmodeをnormalからsearchに変える

日本語形態素解析エンジンとして、Kuromojiを使っているのですがモードを当初は normal にしていました。

詳細は、下記が詳しいのですが、複数の単語が組み合わさった単語をいい感じに分割してトークナイズしてくれるモードとのことで、再現率向上の観点から search モードを採用しました。

kuromoji_tokenizer | Elasticsearch Plugins and Integrations [7.10] | Elastic

www.elastic.co

上記2点のチューニングを行い、改めてABテストを実施したところ、再現率が向上とKPIにしているCTRで既存エンジンと有意差なしまで持っていくことが出来ました。

これから

今後、オートコンプリートや関連検索の提案等の複数検索機能も含めた検索システムを通して、よりママリの検索がユーザの悩みや課題解決につながる機能となっていくよう開発をしていこうと思います。

難易度が高くなるランキング周りの話やカスタム辞書のさらなるチューニング、シノニム(同義語)の整備、 QueryUnderstanding をチームで進めるためのログやモニタリングの整備等、検索めちゃくちゃやること多いなわくわくするなという思いをチームメンバはもっており、今後も走りながら進めていき、少しずつ事例も紹介していければと思っています。

最後にコネヒトでは検索システムを一緒に開発してくれるエンジニアを募集しています。

下記の募集以外にもポジションありますので、少しでも興味もたれた方は、是非気軽にオンラインでカジュアルにお話出来るとうれしいです。

https://hrmos.co/pages/connehito/jobs/00n

hrmos.co

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

こんにちは!@TOC です。今回は弊社が協賛するイベントについて紹介します。

コネヒトは PHP Conference 2022 に協賛いたします

コネヒトではママリを始め、社内の多くのプロダクトが PHP で開発されております(その他、技術スタックを知りたい場合は弊社テックビジョンをご覧ください)

そんなコネヒトはこの度、 PHP Conference 2022 にシルバースポンサーとして協賛いたします!

イベント概要

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

台風が近づいておりますので、気をつけつつオンライン・オフラインで楽しみたいです🙌

また、最新情報は Twitter で告知されるので PHP Conference 公式 Twitter もチェックしてみてください。

@phpcon

最後に

会場では弊社デザイナーが作ってくれた素敵なジョブボードも掲載いたします🙆‍♂️

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

コネヒトでは PHPer を積極採用中です!

hrmos.co

AWS SESでハードバウンスのようなメールがソフトバウンス扱いで届いた場合のしくみと対処について

こんにちは、インフラエンジニアのささしゅう(@sasashuuu)です。 本日はAWS SESにおけるバウンスメール周りのお話をしようと思います。 タイトルにもあるように、ハードバウンスのようなメールがソフトバウンス扱いで届き、気になってサポートへ問い合わせた際のお話をご紹介します。

バウンスメールとは

そもそもバウンスメールとはなんらかの原因により配信できなかったメールのことで、その種類にはソフトバウンスとハードバウンスがあります。

違いは以下のような内容です。

  • ソフトバウンス
    • 一時的な原因による配信失敗のメール(送信先のメールボックスがいっぱいだったなど)
  • ハードバウンス
    • 恒久的な原因による配信失敗のメール(そもそも宛先のメールアドレスが存在しないなど)

詳細な内容は過去のブログでも取り上げておりますので、よろしければご覧ください。

tech.connehito.com

届いたメールの内容

今回届いたバウンスメールの一部は以下のような内容です。

{
    "notificationType": "Bounce",
    "bounce": {
        "feedbackId": "xxxx-xxx-xxx-xxx-xxx-xxx-xxxxxx",
        "bounceType": "Transient",
        "bounceSubType": "General",
        "bouncedRecipients": [
            {
                "emailAddress": "xxxxxxxxxxxx",
                "action": "failed",
                "status": "5.1.1",
                "diagnosticCode": "smtp;550 5.1.1 RESOLVER.ADR.RecipNotFound; not found"
            },
            ...

SESによってハンドリングされたバウンスメールは上記のようなJSONオブジェクトの形で配信されるのですが、いくつか項目をピックアップして見ていきましょう。(※詳細は公式ドキュメントを参照。)

  • notificationType

    • 通知のタイプです。
    • Bounceが入っているのでバウンスメール扱いとなります。
  • bounceType

    • SESによって決定されるバウンスタイプです。
    • Transientがソフトバウンスになります。

      ソフトバウンスであるかどうかを判断するには、SNS 通知の内容を確認します。bounceType  が Transient  の場合はソフトバウンスです。ソフトバウンスのタイプを確認するには、bounceSubType の値を確認します。

      aws.amazon.com

  • diagnosticCode

    • DSNのDiagnostic-Codeフィールドの値です。
    • 配信が不可であった場合に返ってくる応答メッセージかな(?)と解釈しております。
    • ちなみにsmtp;550 5.1.1 RESOLVER.ADR.RecipNotFound; not foundとなっています。いかにも宛先などが見つからなかったようなメッセージですね。
    • なお、SESのデベロッパーズガイドのメールボックスシミュレーターのシュミレートシナリオや、バウンス応答アクションのテンプレート説明などでは、不明なユーザーやメールボックスが見つからなかったことなどを示唆するメッセージが見て取れます。

      バウンス – 受取人のEメールプロバイダー は、SMTP 550 5.1.1 レスポンスコード (「不明な ユーザー」) レスポンスコードで E メールを拒否 します。

      https://docs.aws.amazon.com/ja_jp/ses/latest/dg/ses-dg.pdf

      Mailbox Does Not Exist - SMTP 応答コード = 550、SMTP ステータスコード = 5.1.1

      docs.aws.amazon.com

詳細は後述しますが、今回はこの smtp;550 5.1.1 RESOLVER.ADR.RecipNotFound; not found のレスポンスメッセージがSES側で未知のものであったことがソフトバウンス認定されてしまっていた原因でした。

問い合わせと回答

これまでの内容を踏まえ、以下2点について内容をまとめサポートへ問い合わせをしました。

  • なぜこのようなハードバウンスのような内容のメールがソフトバウンス扱いで届くのか。
  • アカウントレベルのサプレッションリストの使用 のように、任意のソフトバウンスメールのメールついて2回目以降の送信が行われないような機能、または良い方法はあるのか。

得られた回答の要点をざっくりとご紹介します。

まず1点目の「なぜこのようなハードバウンスのような内容のメールがソフトバウンス扱いで届くのか」については以下の内容です。

  • SES は受信MTAからのレスポンスからハードバウンスが発生したかどうかを判定する。
  • しかしながら受信MTAによって異なるレスポンスメッセージを利用するため、SES が把握していないレスポンスメッセージが受信MTAから返却される場合がある。
  • その場合、顧客のバウンス率の評価に悪影響を与えないようにSESではハードバウンスではなくソフトバウンスが発生したものとして扱う。

このようにSESが把握していないレスポンスメッセージに関しては意図的にソフトバウンス扱いとして対処しているとのことでした。

また、本件に関するレスポンスメッセージに関しては問い合わせ後、ハードバウンスと判定されるような修正を反映してくださったようで、今後本件のような事象(ハードバウンスのような内容がソフトバウンスで届く件)が発生した場合は同じようにフィードバックが欲しいとのことでした。(バウンス判定の質の向上のために使用したいとのことでした)

そして2点目の「アカウントレベルのサプレッションリストの使用 のように、任意のソフトバウンスメールのメールついて2回目以降の送信が行われないような機能、または良い方法があるか」については以下の内容です。

  • SES のサービスのみでは実現が難しいため以下のような対応が案としてあげられる。
  • 対象のアドレスについて、手動でサプレッションリストに登録する。
  • Lambda関数にて特定の条件を満たした場合に対象のアドレスをサプレッションリストに登録するコードを実装し、SESのSNS通知と連携する。

問い合わせ時点ではSESの機能で要件を満たすものはないそうで、手動でのオペレーションや他のAWSサービスを組み併せて対応する必要があるみたいでした。

まとめ

最後に本記事の要点を簡単にまとめておきます。 AWS SESで把握していないMTAからのレスポンスメッセージはバウンスレートに影響しないようにハードバウンスであってもソフトバウンスとして扱われます。もしこのような状況に遭遇した場合はサポートへフィードバックを送り、解消を図るのが良いと思います。また、そうでなくとも任意のソフトバウンスメールを2回目以降に送らせないようにするには手動でサプレッションリストに登録する、もしくはSES・Lambda・SNSなどのサービスを組み合わせて独自のシステムを構築するのが良いと思います。 もし同じような現象に遭遇し、お困りの方がいれば本記事の内容をご活用いただければと思います。

AWS Step FunctionsのRestoreDBClusterFromSnapshotだけではクラスタに紐づくインスタンスは作成されない

こんにちは、インフラエンジニアのささしゅう(@sasashuuu)です。

本日は、Step Functionsを用いてデータの同期更新システムを構築した際に少しだけハマったポイントについてご紹介です。

Step Functionsとは

Step FunctionsとはAWSの各種サービスを組み合わせた一連の処理の自動化などがローコードで行えるAWSのサービスです。

aws.amazon.com

弊社でも過去にいくつかStep Functionsを使用した導入事例を記事にしているので、もしご興味があればご覧ください。いくつかピックアップしておきます。

tech.connehito.com tech.connehito.com tech.connehito.com

やりたかったこと

前述したように、Step Functionsのワークフローを用いてデータの同期更新をするシステムを構築していました。その過程で同期元のRDSのスナップショットからAuroraクラスタおよびそれに紐づくインスタンスを復元しようとワークフローを組んでいました。

以下はそのフローの一部分です。

ここでは、DescribeDBClusterSnapshotsのステートで処理を実行し、取得したスナップショットの識別子を元にRestoreDBClusterFromSnapshotのステートでクラスターおよびクラスタに紐づくインスタンスを復元しようとしていました。

しかし、待てど暮らせどインスタンスは現れてくれません…。

以前検証のためにコンソールからスナップショットを使用して、クラスターの復元をおこなったことがありました。その際は対象のスナップショットの画面から「アクション」>「スナップショットを復元」>「DBクラスターを復元」で「えいっ、ポチッ」とやればインスタンスも丸っと綺麗に復元できたんだけどな…。(※以下、オペレーション時のイメージのキャプチャ)

今回Step Functions上で同じようなことを再現したかったのですが、どうもうまくいきません。

何が起こっていたか

結論、叩くAPIが足りていませんでした。

Step Functions上でRDSのスナップショットからの復元という要件を満たすには、RestoreDBClusterFromSnapshotとはまた別のステートでインスタンス作成を行う処理が必要でした。

前述したAWSコンソールからの実行時の例では「DBクラスターを復元」というsubmitボタンを押下した後、裏でよしなにクラスターの作成とインスタンスの作成のAPIの実行が両方走っていたのです。コンソールから「DBクラスターを復元」を実行した際に実行されるAPIは次の通りです。

この辺りは実行後、Cloud Trailのログなどを見るとよくわかります。

やらなければいけなかったこと

下記のようにワークフローを修正すれば良いです。

CreateDBInstanceのステートを追加しました。

これで晴れて、クラスターおよびインスタンスを含めスナップショットからの復元を行うことができます。

補足

上記の挙動は公式ドキュメントにも明記されていました。

コンソールを使用して DB クラスターを復元する場合、Amazon RDS は自動的に使用する DB クラスターのプライマリインスタンス (ライター) を作成します。RDS API を使用して DB クラスターを復元する場合は、DB クラスターのプライマリインスタンスを明示的に作成する必要があります。

docs.aws.amazon.com

現時点では、コンソールを使用したオペレーション時のみプライマリインスタンスを作成する挙動となっているようです。

今回はStep Functionsを用いたスナップショットからのRDS復元時に少しだけハマったポイントについてご紹介しました。また、ハマりポイントの小ネタなどがあれば更新しようと思います