エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

GiNZAと患者表現辞書を使って患者テキストの表記ゆれを吸収した意味構造検索を試した

f:id:abctail30:20210217022649j:plain

エムスリーエンジニアリンググループ AI・機械学習チームの中村(@po3rin) です。 好きな言語はGo。仕事では主に検索周りを担当しています。

最近「医療言語処理」という本を読んで、医療用語の表記ゆれ吸収や意味構造検索などについて学びました。

医療言語処理 (自然言語処理シリーズ)

医療言語処理 (自然言語処理シリーズ)

そこで今回はElasticsearchと患者表現辞書を使った意味構造検索がどのくらい実戦投入できるかを簡単に試したので、概要と実装方法を簡単にご紹介します。

患者テキストの表記ゆれ

この節では医療言語処理における患者テキストの表記ゆれ問題について言及し、MEDNLPから公開されている患者表現辞書を紹介します。

患者テキストの表記ゆれとは

医療言語処理における表記ゆれと聞いてまず思い浮かべるのは医療用語の揺れでしょうか。「癌、ガン」「産じょく、産褥」「片頭痛、偏頭痛」など日本語では漢字、カタカナ、平仮名の混合表記があり、表記ゆれが非常に大きいのが課題になっています。

一方で患者が記述する文書に対する言語処理の壁として挙げられるのが患者テキストの表記ゆれです。患者は医療用語を使わずに非医療用語を使うため、医療用語の揺れ以上に患者テキストではより大きなギャップが存在します。例えばICD-10(国際疾病)コードのR098で定義されている胸部不快感という病名を訴える場合「胸が苦しい」と報告する患者もいれば「胸がムカムカする」と表現する患者もいます。

f:id:abctail30:20210217232630p:plain

もちろん、患者テキストの検索というタスクでも表記ゆれは大きな課題です。弊社ではAskDoctorsという医師Q&Aサービスを運用しており、Q&Aを検索できる機能があり、まさに患者テキストを元に、検索を実施するサービスです。

www.askdoctors.jp

例えば、トークンベースでの基本的なヒット判定では「胸が苦しい」で検索した時に「胸がムカムカ」がヒットしません。医師への質問で同じ病気を別の表現で説明している場合、検索意図にあったドキュメントが返せず検索の適合率が下がってしまいます。

単語の表記ゆれに対してはシノニムが対策として挙げられますが、症状を表現する場合は単語レベルでのゆれではないので難しいところです。例えば「苦しい」と「ムカムカ」を類似語として設定するのはやり過ぎに感じます。そのため患者テキストの表記ゆれは検索においてはすぐに解決できる問題ではありません。

MEDNLPの患者表現辞書

患者テキストの表記ゆれに対する手札としてMEDNLPで公開されている患者表現辞書が挙げられます。 sociocom.jp

患者表現辞書は、患者が用いる表現に関する辞書であり、患者の表現に対応する、標準病名や部位、ICD-10コードなどが列挙されています。

f:id:abctail30:20210216204348p:plain
患者表現辞書

この辞書でユーザーの検索クエリの全てを処理できる訳ではありませんが、これを使えば「胸が苦しい」や「胸がムカムカ」のような表記ゆれを吸収できそうです。

トークンによる検索の課題と対策の検討

この節ではトークンによる検索の課題を紹介し、その対策となりうる意味構造検索を紹介します。

主語が違うのにヒットしちゃう?

トークンマッチで検索する場合、普通にやると「頭が痛い」というクエリは「頭 痛い」に形態素解析され「頭は正常だが胃が痛い」というドキュメントがヒットしてしまいます。これは検索意図を汲んでいないので検索ノイズと言えます。

f:id:abctail30:20210216211333p:plain
検索意図とずれた検索

フレーズマッチの場合でも、例えばslop=0に設定した場合、「頭が痛い」というクエリで「頭がズキズキと痛い」にヒットしてくれないのでフレーズマッチでは完璧に解決しきれない問題です。

この問題を解決するためには単語間の係り受け解析を用いた意味構造検索が役立ちます。

意味構造検索

意味構造検索とはテキストに対して係り受け解析を行い文章の構造を捉えて検索することを指します。係り受け解析では下記のように文章の単語間の関係を解析します。

f:id:abctail30:20210216212122p:plain
係り受け解析

この係り受け解析により、「頭が痛い」と「頭は正常だが胃が痛い」では痛いに繋がる主語名詞の関係が一致しないと判断でき、検索ノイズが減らせます。

係り受け解析と患者表現辞書を使った意味構造検索の実装

ここまでで患者テキストのゆれや、意味構造解析の概要を説明しました。ここからは実際にElasticsearch上で係り受け解析を使った意味構造検索を実装してみます。

今回の実装に利用するモジュールやPythonのバージョンは下記です。

[tool.poetry.dependencies]
python = "^3.9"
spacy = "^2.3.5"
ginza = "^4.0.5"
fastapi = "^0.63.0"
uvicorn = "^0.13.3"
joblib = "^1.0.1"
elasticsearch = "^7.11.0"
pandas = "^1.2.2"
numpy = "^1.20.1"

そして、今回は下記のようなアーキテクチャで実装していきます。

f:id:abctail30:20210218015057p:plain
今回試す検索アーキテクチャ

患者表現辞書を使った係り受け解析

係り受け解析ではGiNZAを利用します。 GiNZAは日本語の自然言語処理ライブラリであり、自然言語処理フレームワークspaCy に対応しています。内部の形態素解析器はSudachiが利用されています。

github.com

GiNZAを使うと下記コードのように日本語の係り受け解析や固有表現抽出が簡単に行えます。

# Jupyter Notebookeでvisualize想定
class DependencyAnalysis:
    def __init__(self):
        self.nlp = spacy.load('ja_ginza')

    def run(self, text):
        doc = self.nlp(text)

        displacy.render(doc, style="dep", jupyter=True, options={'distance': 90})
        displacy.render(doc, style="ent", jupyter=True,)


dependency = DependencyAnalysis()
dependency.run("頭は正常だが胃が痛い")

Jupyter Notebook上で実行すれば係り受け解析の結果と固有表現抽出の結果を確認できます。

f:id:abctail30:20210216215859p:plain
係り受け解析と固有表現抽出の実行結果

固有表現抽出ではANIMAL_PARTタグで動物の体の部位が抽出できています。これを使えば体の部位に紐づく構文のみを取得できそうです。

それでは体の部位の症状に関する係り受け結果だけを返すクラスDependencyAnalysisを作ります。

class DependencyAnalysis:
    def __init__(self) -> None:
        self.nlp = spacy.load("ja_ginza")

    def run(self, text: str) -> dict[str, str]:
        """
        体の部位を含む主語-名詞,関節目的語の構文を抽出します。
        """
        doc = self.nlp(text)

        ent_words = [
            ent.text for ent in doc.ents if ent.label_ == "Animal_Part"]

        deps = []
        for sent in doc.sents:
            for token in sent:
                if (
                    token.dep_ in ["nsubj", "iobj"]
                    and token.lemma_ in ent_words
                    and len(sent) >= token.head.i
                ):
                    deps.append(f"{token.lemma_}->{sent[token.head.i].lemma_}")

        return deps

今回は簡単のために「主語名詞(nsubj)」「関節目的語(iobj)」の関係のみを抽出し、その関係を「主語->名詞」のような形にフォーマットした文字列として扱っていきます。これによってグラフマッチング問題になって重くなるのを回避しています。

続いてDependencyAnalysisを使って患者表現辞書を扱うクラスPatientExpressionCoupusを作成します。PatientExpressionCoupusでは患者表現辞書を係り受け解析に通して、pandas.DataFrameに変換します。これを辞書として病名に紐づく患者表現を全て取得します。

f:id:abctail30:20210217014046p:plain

それではPatientExpressionCoupusのコードを書いていきます。

class PatientExpressionsCoupus:
    def __init__(self) -> None:
        self.df = {}
        self.da = DependencyAnalysis()

    def get_symptom_expressions(self, desease: str) -> list[str]:
        """
        病名に紐づく患者表現一覧を取得する
        """
        expressions = self.df[self.df["標準病名"] == desease]["出現形"].tolist()
        return expressions

    def get_symptom_deps(self, desease: str) -> list[str]:
        """
        病名に紐づく係り受け構文を取得する
        """
        deps = self.df[self.df["標準病名"] == desease]["deps"].tolist()
        return deps

    def get_deseases(self, expression: str) -> list[str]:
        """
        患者表現に紐づく病名を取得する
        """
        deps = self.da.run(expression)
        deseases = self.df[self.df["deps"] == ",".join(deps)]["標準病名"].tolist()
        return deseases

    def analyze(self, text: str) -> str:
        """
        患者表現を係り受け解析して検索のkeyになるようにstringに変換して返す
        """
        deps = self.da.run(text)
        if len(deps) == 0:
            return np.NaN
        return ",".join(deps)

    def load_from_csv(self, file) -> None:
        """
        患者表現辞書をpands.DataFrameに変換
        """
        self.da = DependencyAnalysis()
        with codecs.open(file) as f:
            df = pd.read_table(f, delimiter=",")

        df = df.loc[:, ["出現形", "標準病名"]]
        df["deps"] = df.apply(lambda x: self.analyze(x["出現形"]), axis=1)
        df = df.dropna(how="any")
        self.df = df

PatientExpressionsCoupusの動作確認をしてみましょう。

pec = PatientExpressionsCoupus()
pec.load_from_csv("corpus/D3_20190326.csv")

d = DependencyAnalysis()
dep = d.run("胸が苦しい")
print(dep)

deseases = pec.get_deseases(t)
print(deseases)

expression_list = []
for d in deseases:
    expression_list.extend(pec.get_symptom_expressions(d))
print(expression_list)

corpus/D3_20190326.csvは患者表現辞書をcsvに変換したものです。 これを実行すると係り受け結果、患者表現に紐づく病名、病名に基づく患者表現が取得できています。また、「胸が苦しい」から様々な「胸痛」や「胸部不快感」を表現する患者表現が確認できます。

[{'dep': 'nsubj', 's': '胸', 'h': '苦しい'}]

['胸痛', '胸内苦悶', '胸部不快感', '胸部不快感']

['右の胸が痛む', '右側の腹部が痛むこと', '急に病的に胸が痛い', '胸がいたい', '胸がいたか', '胸がキュッと', '胸がキュンキュン', '胸がキリキリ', '胸がギリギリと', '胸がグッと', '胸がズキズキ', '胸がズキズキする', '胸がチク
チク', '胸が急に痛くなる', '胸が急に痛む', '胸が苦しい', '胸が切ない', '胸が痛い', '胸が痛いこと', '胸が痛い発作', '胸が痛くなる', '胸が痛くなる発作', '胸が痛む', '胸が痛む', '胸が締め付けられる', '胸が突然痛む', '左の胸
が痛い', '左の胸が痛くなる', '左の胸が痛む', '左胸がいたい', '左胸が痛いこと', '左側の胸が痛い', '心臓が痛い', '心臓が痛くなる', '突然胸が痛くなる', '脇腹が痛い', '胸がきつい', '胸がキュー', '胸がキューッ', '胸がきゅっと', '胸がキュッとなる', '胸がくるしい', '胸がしめつけ', '胸がしめつけられる', '胸がしんどい', '胸がつまる', '胸が圧迫感', '胸が押さえつけられる', '胸が押さえつけられるような感じ', '胸が押されるような', '胸が押される感じ', '
胸が詰まる', '胸が苦しい', '胸が締め付け', '胸が締め付けられる', '胸が締め付けられるようだ', '胸や心臓が苦しい', '胸部が押されるような痛み', '心臓がきゅー', '心臓がキュー', '心臓がキューッ', '心臓がきゅっと', '心臓がキュ
ッとなる', '心臓がくるしい', '心臓がしめつけ', '心臓がしんどい', '心臓がつまる', '心臓が圧迫感', '心臓が詰まる', '心臓が苦しい', '心臓が締め付け', '心臓が締め付けられる', '胸がおかしい', '胸がくるしい', '胸がザワザワする', '胸がつかえる', '胸がなんかおかしい', '胸がへん', '胸がムカムカ', '胸がむかむかする', '胸がモヤモヤ', '胸が圧迫される感じ', '胸が押されるような感じ', '胸が気持ち悪い', '胸が苦しい感じ', '胸が苦しくなる', '胸が重い', '胸
が正常ではない感じ', '胸が痛い', '胸が潰れる感じ', '胸が締め付けられているような感じ', '胸が締め付けられるような感覚', '胸が締め付けられるように痛い', '胸が締め付けられる感じ', '胸が締め付けられる感じがする', '胸が不快', '胸の上の方(おっぱいより上)が押しつぶされるような感じ', '胸部が悪い', '心臓が悪い', '胸がおかしい', '胸がくるしい', '胸がザワザワする', '胸がつかえる', '胸がなんかおかしい', '胸がへん', '胸がムカムカ', '胸がむかむかする
', '胸がモヤモヤ', '胸が圧迫される感じ', '胸が押されるような感じ', '胸が気持ち悪い', '胸が苦しい感じ', '胸が苦しくなる', '胸が重い', '胸が正常ではない感じ', '胸が痛い', '胸が潰れる感じ', '胸が締め付けられているような感じ', '胸が締め付けられるような感覚', '胸が締め付けられるように痛い', '胸が締め付けられる感じ', '胸が締め付けられる感じがする', '胸が不快', '胸の上の方(おっぱいより上)が押しつぶされるような感じ', '胸部が悪い', '心臓が悪い']

毎回、患者表現辞書から係り受け解析を通すのは時間がかかるのでpicklejoblibで保存してロードできるようにしておくと良いでしょう。

患者表現辞書の表現をクエリに展開するAPI

ここまできたらあとはElasticsearchを叩くAPIを作るだけです。今回はPythonの軽量WebフレームワークであるFastAPIを使って構築します。

APIはドキュメントの登録(/topics)と検索(/topics/search)だけです。Elasticsearchでインデックスする検索対象データはAskDoctorsの質問タイトルのみだけを想定しています。

fastapi.tiangolo.com

app = FastAPI()
es = Elasticsearch("http://localhost:9200")

pec = PatientExpressionsCoupus()
pec.load("symptoms_expression_dict.joblib")

d = DependencyAnalysis()


@app.get("/topics/search")
def topics(q: str = None):
    deseases = pec.get_deseases(q)

    deps = []
    for d in deseases:
        deps.extend(pec.get_symptom_deps(d))

    expression_queries = [{"match": {"deps": e}} for e in deps]
    if len(expression_queries) == 0:
        return {}

    query = {
        "query": {"bool": {"should": expression_queries, "minimum_should_match": 1}}
    }
    q = json.dumps(query)
    return es.search(index="topics", body=query, size=3)


@app.post("/topics")
def topics(body: dict = Body(None)):
    deps = d.run(body["title"])

    topic = {
        "id": body["id"],
        "title": body["title"],
        "deps": deps,
    }
    return es.create(id=body["id"], index="topics", body=topic)

これで患者テキストの表記ゆれを吸収した意味構造検索の完成です。

動作確認

ドキュメント登録します。

POST localhost:8000/topics
{
    "id": 1111,
    "title": "お腹が痛むが首は正常"
}

データを見ると係り受け結果を含んだデータとして保存できていることを確認できます。

{
    // ...
    "hits": {
        "total": {
            "value": 1,
            "relation": "eq"
        },
        "max_score": 0.8630463,
        "hits": [
            {
                "_index": "topics",
                "_type": "_doc",
                "_id": "1113",
                "_score": 0.8630463,
                "_source": {
                    "id": 1113,
                    "title": "お腹が痛むが首は正常",
                    "deps": [
                        "お腹->痛む",
                        "首->正常"
                    ]
                }
            }
        ]
    }
}

次に検索です。結果を見ると、「お腹が痛い」と「お腹がチクチクする」の表記ゆれを抑えて検索ができています。

curl localhost:8000/topics/search?q=お腹がチクチクする
{
    // ...
    "hits": {
        "total": {
            "value": 1,
            "relation": "eq"
        },
        "max_score": 0.8630463,
        "hits": [
            {
                "_index": "topics",
                "_type": "_doc",
                "_id": "1113",
                "_score": 0.8630463,
                "_source": {
                    "id": 1113,
                    "title": "お腹が痛むが首は正常",
                    "deps": [
                        "お腹->痛む",
                        "首->正常"
                    ]
                }
            }
        ]
    }
}

一方で、「首」も「痛い」もtitleにあるので、「首 痛い」というトークンでAND検索を行った場合「お腹が痛むが首は正常」がヒットしてしまいますが、係り受け解析を行なっているので「痛い」の主語を正しく判断し、ヒットを回避しています。

curl localhost:8000/topics/search?q=首が痛い
{
    // ...
    "hits": {
        "total": {
            "value": 0,
            "relation": "eq"
        },
        "max_score": null,
        "hits": []
    }
}

これで患者テキストの表記ゆれを吸収した意味構造検索ができました。

実戦投入までの課題

実際の検索クエリでは今回のように助詞が必ず含まれているわけではなく、ほとんどの場合は「発熱 咳 ない 鼻 出る」など助詞が省略された形でクエリを処理する必要があります。デフォルトのGiNZAでは助詞がないと精度良く係り受け解析ができません。

また、患者表現辞書だけではユーザーのクエリを全て捌ける訳ではないので、いつもの検索ロジックと合わせて使う必要がありそうです。

そして今回は体の部位を含む簡単な文章だけを対象にしましたが、さらにサポート範囲を広くしていくのであればチューニングはもちろん、医療用語辞書の導入、延いては患者表現辞書の独自拡張も検討していくと良さそうです。

まとめ

今回はGiNZAと患者表現辞書を使ってElasticsearchによる意味構造検索を試してみました。これからも検索改善に繋がる技術を実験していければと思います。

We're hiring !!!

エムスリーでは検索基盤の開発&改善を通して医療を前進させるエンジニアを募集しています! 社内では最近、検索チームを中心に「Elasticsearch & Lucene コードリーディング会」が発足し、検索の仕組みに関する議論も活発です。

「ちょっと話聞いてみたいかも」という人はこちらから!

jobs.m3.com

Reference

患者表現辞書 sociocom.jp

GiNZA+Elasticsearchで係り受け検索の第一歩 acro-engineer.hatenablog.com

医療言語処理

医療言語処理 (自然言語処理シリーズ)

医療言語処理 (自然言語処理シリーズ)

今日のリモート雑談テーマを決める魔法のスプレッドシートを運用している話

はじめに

こんにちは。エムスリー AI・機械学習チームの河合(@vaaaaanquish)です。

IT業界にコロナによるリモートワークの大きな波が来て約1年、私達のチームでは、毎日10分程、少なくなった雑談を補う場所として「雑談夕会」を実施しています。 本記事は、その夕会のために私が作成した、雑談テーマを決めるスプレッドシートについてと、その運用に関する報告です。

  • はじめに
  • 雑談夕会の目的
  • 魔法のスプレッドシートについて
  • 日付をシードにした乱数生成
  • テーマの内容について
    • 「興味や動向」「情報収集」に関連したテーマ
    • 抽象度のグラデーション
    • 状態の可視化
  • 雑談テーマシートで注意すべきこと
    • ネガティブなテーマの量は減らす
    • 雑談の枠を超えない
  • おわりに
  • We are hiring!

 

雑談夕会の目的

雑談夕会は、「自分の弱みをさらけ出す」「理解しリスペクトする」という、株式会社FORCASさんの自己紹介定例などの先行事例を参考に、チームリーダーであった西場さんが始めた施策です。 journal.uzabase.com

雑談夕会の大きな目的は「チームラーニング」です。

互いに技術や仕事でリスペクトする事で「学びを与えるような指摘」を気持ちよく行える関係になることを目指しています。

そういった目的の中で、仕事のやり取りにおけるより良い理解やリスペクト、健全なコンフリクトを増やしたい反面で、雑談友会におけるテーマ決めのような準備や雑談の内容、参加自体が誰かの負荷になっては元も子もない、というところから生まれたのがこのスプレッドシートです。

続きを読む

AWS Firewall Manager を導入してみた話

この記事はエムスリーSREがお届けするブログリレーの18日目です。

こんにちは、エムスリーエンジニアリンググループの高澤です。 Unit4(医療系ポータルサイトm3.comの開発・運営が担当のチーム)でチームSREを担当しています。 こちらのサイトのセキュリティ施策の一環として、Firewall Managerを導入してみたので、その話を紹介したいと思います。

チームSRE/コアSREについては、リレー初日の記事をご覧ください。 www.m3tech.blog

  • AWS Firewall Manager とは
  • 導入からWeb ACLの作成まで
    • 1. IP Setsの作成
    • 2. Rule Groupsの作成
    • 3. Security Policy の作成
  • 導入後: ログの改善
  • まとめ
  • We are Hiring!
続きを読む

こんばんは、X-Forwarded-For警察です

エムスリーエンジニアリンググループ製薬企業向けプラットフォームチームの三浦 (@yuba)です。普段はサービス開発やバッチ処理開発をメインにやっておりますが、チームSREに参加してからはこれに加えて担当サービスのインフラ管理、そしてクラウド移行に携わっています。

今回はそのクラウド移行の話そのものではないのですが、それと必ず絡んでくるインフラ設定に関してです。

続きを読む

パイプラインツールgokartのキャッシュ競合を解消した話

はじめに

 エムスリーエンジニアリンググループ AIチームの池嶋です。はじめてのテックブログ投稿です。

 AIチームでは機械学習プロジェクトのデータパイプライン構築にgokartというツールを使用しています。今回はこのgokartで発生していたキャッシュ競合を解消した話について紹介します。

gokart

gokartとは

 gokartというのはAIチームが中心に開発しているデータパイプライン構築のためのツールで、Spotify社の開発するパイプラインツールluigiのwrapperです。S3やGCSといったクラウドストレージとのデータ入出力をサポートしたり、中間ファイルをキャッシュとして保存することで実験を再現をしやすくしたりします。当ブログでは過去にも機械学習プロジェクト向けPipelineライブラリgokartを用いた開発と運用 - エムスリーテックブログ などで紹介されています。

 Github上でOSSとして公開されており、AIチームのメンバーを中心に開発が進められています。

github.com

gokartのパイプライン構成

f:id:mski_iksm:20210108225003p:plain
gokartのパイプライン構成例

 上図はgokartで実装したパイプラインのイメージ図です。gokartではTaskというクラス単位で処理を記述します(上図の青boxで示した部分)。それぞれのタスクでは依存タスクを指定することで「このタスクの前にどのタスクを実行しておく」ということを指定し、パイプラインの構築ができます。

 各Taskは、完了時にpickleなどの形式で実行結果をキャッシュファイルとして保存します。キャッシュファイルはTaskのパラメータをハッシュ化した名前で作成され、Taskのパラメータが変わらない場合は同じファイル名になります。Taskのパラメータを変えずに再度実行した場合、すでにキャッシュファイルが存在する状態のため、Taskの処理自体は実行されず、下流Taskにキャッシュファイルに保存されている処理結果だけを渡すことになります。そのため計算資源、時間を省力化できます。

f:id:mski_iksm:20210108225115p:plain
機械学習プロジェクトにおけるgokartパイプライン例

 計算資源・時間の省力化は、機械学習プロジェクトにおいては学習モデルのパラメータを少しだけ変えた実験を多数回実行する際などに強さを発揮します。上図はある機械学習プロジェクトにおけるパイプラインの例です。学習データから特徴量を作成するタスクから、モデル学習、モデル評価、推論用の特徴作成を経て最後の推論結果を出力するタスクまでが1つのパイプラインでつながっています。実行時には最下流の推論のタスクを実行すると、依存するタスクを遡って特定し、上流のタスクから実行されます。モデルの学習においてパラメータを変更し多数回の実験を行った場合、共通する特徴量作成処理についてはキャッシュファイルを利用できるため、2回目以降の処理では省力化が可能となります。

生じた課題: キャッシュの競合

 パイプライン構築に役立つgokartでしたが、稀にTaskのキャッシュが競合する場合があるという問題が生じていました。これは「複数のアプリケーション」で「同一のタスク」を「同一のパラメータ」で「同時」に実行した場合に発生することがありました。

f:id:mski_iksm:20210108225231p:plain
キャッシュの競合

 上図はキャッシュ競合が発生するパターンを図示したものです。gokartを用いて実装されたアプリAとアプリBに、同じパラメータで動作する同じTask「タスクA」があったとします。この状況下で2つのアプリをほぼ同時に実行すると、両方のアプリケーションにおいてタスクAが実行されてしまいます。それぞれのタスクAの完了時に2アプリケーション同時にストレージへの保存が行われるためキャッシュファイルが競合する可能性があるというものです。

 2アプリケーションが同時ではなくアプリA—>Bという順に実行した場合、先に実行したアプリAにおいてキャッシュファイルが作成されます。あとに実行したアプリBでは、すでにストレージに保存されているタスクの実行結果のキャッシュファイルを読み込むだけになるため、キャッシュファイルの競合は発生しません。しかし様々なアプリケーションで共通する特徴量を作成する場合など、タスクが競合するケースは十分に想定されるものとなっていました。

解決案の選定

キャッシュファイルの競合問題を解消するために以下3つの手法を比較検討しました。

解決案1: キャッシュ保存先を分ける

f:id:mski_iksm:20210108225245p:plain
キャッシュの保存先を分ける
 キャッシュ競合問題の最も手軽な解決法はgokartのキャッシュ保存先を分けるというものです。gokartではgokart.TaskOnKart().workspace_directoryでキャッシュファイルの保存先を指定できます。アプリケーションごとにキャッシュファイルの保存先を振り分けることで、キャッシュファイルの競合は完全になくすことができます。しかしこの方法ではアプリごとにタスクの実行し直しが必要になってしまうので、既に作成されたキャッシュファイルを使い回すという省力化のメリットを活かせなくなってしまいます。

解決案2: luigi central schedulerを使用

 luigiにはcentral schedulerというTaskのスケジューリングを集中管理する機能があります。 Using the Central Scheduler — Luigi 2.8.13 documentation central schedulerは同一のタスクが同時に実行されないようにTaskをスケジューリングするため、キャッシュファイル競合の問題を回避できます。しかし、スケジューラをスケールできないため、多数のアプリケーションが走りうる状況下ではあまり向いていない方法と考えられます。(*注: 公式documentにもdevelopmentには良いがproduction usageには向いていないとの記述がある。)

解決案3: キャッシュファイルをロックする

 キャッシュファイルを同時に複数のアプリケーションから操作できないよう、キャッシュファイル操作時に排他ロックを作成する方法です。gokartの処理に修正を加える必要がありますが、作成したキャッシュファイルをアプリケーション間で使い回せる点やスケジューリング自体はアプリケーションごとに行える点からこちらの案を採用しました。

 排他ロックの管理にはRedisを使用することにしました。gokartのキャッシュロックを管理するためのRedisサーバーを立てておき、gokartではキャッシュファイルを作成・読み込み・削除する際に他のアプリケーションでロックが取られていないかを確認します。もしロックが取られていなければそのままキャッシュファイル操作を実行、取られていたらロック解放を待つという仕様にしました。

キャッシュファイルロック機能使用時の構成図

f:id:mski_iksm:20210108225235p:plain
キャッシュロック機能使用時の構成図(アプリAがキャッシュAを作成中のためRedisでロックを保持している状態)

 上図はキャッシュファイルのロック機能を使った場合の構成図です。AIチームではアプリケーションの実行にKubernetesを、データの保存にGCSを用いており(*AWSの利用などこれ以外の構成も当然あります)、その環境に基づいた図となっています。 同一のタスクAを含む2つのアプリA/Bをほぼ同時に実行したとし、わずかに先に実行したのがAだとします。アプリAは以下の順で動作します。

  1. アプリAはRedis Serviceを通じてgokartのロックを管理するRedisサーバーにアクセスをします。
  2. まだ他アプリケーションにロックが取られていないので、アプリAとしてタスクAのロックを取得します。
  3. アプリAはGCS上にキャッシュファイルを作成します。
  4. キャッシュファイル作成完了後、アプリAのロックを解放します。

1.~3.の間でアプリBもRedisサーバーへのアクセスをしますが、既にアプリAによってlockが取られているため処理を一時停止し、アプリAにおける4.が完了後に処理を再開します。アプリA/Bで同じタスクを同時実行したとしても、この仕組みでキャッシュファイルの衝突を回避しています。

更に発生した問題とその解消

 上記の仕様でキャッシュロック機能を実装し、チーム内で検証していたところ、2点問題が生じていました。

異常終了時にロックが残る問題

 1点目の問題はTaskの異常終了時にロックが残ってしまう問題です。gokartを使ったTaskの実装にバグが入り込むなどしてTaskが正常完了しなかった場合、Redisサーバーにロックが残ってしまう問題が生じていました。

 そこでロック保持の考え方を「不使用になるまで保持し続けて、不要になったら解放する」から「短時間で揮発するロックを持ち、保持している間は残り時間を延長し続ける」へ変更しました。こうすることで、たとえgokart Taskの走っているjobがロックを保持した状態でKILLされても、揮発までの一定時間を待つことでロックが自然と揮発し、ロックが解放されるようになりました。

プリエンプティブルインスタンス問題

 エムスリーではGCP料金を圧縮するために一部環境では積極的にプリエンプティブル仮想マシンを採用しています。プリエンプティブル仮想マシンとはGCPで使用可能なインスタンスの1つで、稼働中に停止させられたり、24時間後に必ず停止させられたりといった制限がある代わりに、料金を非常に安く(最大80%割引)抑えられるインスタンスです。

 当初、Redisサーバーもプリエンプティブルインスタンス上で稼働しており、時折停止・再起動していました。gokartを使用したアプリケーションが稼働しているタイミングで停止・再起動するとRedisのlockが消えるなどといった問題が生じていました。この問題を回避するため、単純にRedisサーバーはプリエンプティブルではない通常のインスタンス上で実行させるよう設定しました。

キャッシュロック機能の使い方

 上記問題を解消し安定して稼働していることが確認されたため、キャッシュロック機能はgokartのmasterにマージされています(リリースバージョンは>=0.3.22)。ここではキャッシュロック機能の使用方法を紹介します。

1. まず、ロック記録用のRedisサーバーを立ち上げます。$ redis-serverとすることでlocalhostでRedisを起動できます。

2. Redisサーバーのhostnameとport番号をconfigに書き込みます。gokartが読み込むconfigに以下のように追記します(Redisをlocalhostでデフォルトのポートで実行している場合)

[TaskOnKart]
redis_host=localhost
redis_port=6379

あるいは、gokartの実行時にredis-hostredis-portの引数を設定することでも設定できます

before: python main.py sample.SomeTask --local-scheduler9

after: python main.py sample.SomeTask --local-scheduler --redis-host=localhost --redis-port=6379

3. あとは通常通りgokart Taskを記述する

以上の設定でgokartでキャッシュ確認時にロックする機能を使用可能になります。

we are hiring

 エムスリーではエンジニアを随時募集しております!

 エムスリーではOSS活動を奨励しており、活動は技術向上として評価されます。AIチームでは機械学習モデルの開発だけでなく、こうしたツール開発といった足回りの改善も積極的に行っています。機械学習に強い人だけでなく、エンジニアリングをやりたいという人も是非ご応募お待ちしております!

エンジニアリンググループ 募集一覧 / エムスリー

jobs.m3.com

コアSREチームからサービスチーム側に落下傘してみてた話

皆さんこんにちは、エンジニアリンググループの高橋(@tshohe1)です。
この記事はエムスリーSREがお届けするブログリレーの15日目です。

他の記事でも何度か説明されていますが、エムスリーでは2019年頃からチーム横断的なシステムを管理する「コアSRE」とは別に、サービスチーム内にて各サービスのインフラを重点的に見る「チームSRE」というポジションを新たに設けています(チームSRE化の流れの詳細については下記ブログリレー最初の記事*1を御覧ください)。

私は入社時点ではコアSRE(当時はまだインフラチーム*2)として働いていましたが、2019年頃からサービスチーム側SREと兼務したりコアSREに戻ったりまたチーム側SREに移動したりとふらふらしている謎の存在になっていました。
現時点ではコア/チーム側両方に所属していた者はいないはずなので、本記事ではコアSRE側の視点/チームSRE側の視点でどのような差があったのか、これまでの経験から得たものを簡単にまとめてみたいと思います。

f:id:tshohe:20210201130411p:plain
本文とあまり関係はないですが"木星の衛星エウロパの海に生息するといわれる魚のような生物"の画像です(余談ですがチームSRE推進は最初SREサテライト計画と言われていました)

続きを読む

担当マイクロサービスのSLI/SLOを見直そうと思ったんだ

エムスリーエンジニアリンググループの関根(@sekikatsu36)です。 この記事はエムスリーSREがお届けするブログリレーの14日目です。

今回、私のチームが担当しているサービスのSLI/SLOを見直すこととなり、あらためて「マイクロサービスのSLI/SLOとは」を自分なりに考える機会ができたため、その話を紹介したいと思います。 まだ実行中で結論は出ておらず、内部でも議論の余地があるものですが、何かご参考になる点があれば幸いです。

続きを読む