2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

レシピ名を考えてくれるLLM をSupervied Fine-tuining で作る

Posted at

今回はSupervied Fine-tuining (SFT) の練習として、材料リストからレシピ名を考えてくれるLLM を作成します。具体的な例は以下です。
材料リスト: ['卵白', '砂糖', 'アーモンドパウダー', 'ミルクチョコレート']
レシピ名: 簡単なのに美味すぎる!チョコマカロン


本記事はデータ準備 → 学習 → 評価の3 部構成でまとめています。

データ準備

レシピデータは楽天レシピAPI で取得できる全量を使用しました。生データのままだと、レシピの重複や特殊文字が含まれている場合があるので、データ整形を行います。

データ取得

下記のコードでは全カテゴリリストを取得した後に、各カテゴリごとのレシピを取得するようになっています。1 カテゴリで上位4 件のレシピが取れるので、大体8000 件のレシピが取得できます。取得したデータはtsv ファイルとして保存します。csv だとコンマが含まれる場合におかしなところで分割される可能性があるので、tsv ファイルに保存しています。

※コードは下記の記事を参考にし作らせていただきました。
Pythonで楽天レシピAPIからレシピを取得する

※API 叩く時に必要なアプリID の取得は以下を参考にしました。
楽天APIを使って人気レシピを取得してみた

get_recipe.py
import requests
import pandas as pd
import time
import json

# 楽天レシピAPIの基本URLとAPIキー設定
RAKUTEN_API_URL = "https://app.rakuten.co.jp/services/api/Recipe/CategoryRanking/20170426"
RAKUTEN_APP_ID = "{your_app_id}"  # 自分のAPIキーを入れてください
# アウトプットファイル名の設定
OUTPUT_FILE_PATH = "rakuten_recipes.csv"

# レシピカテゴリリストを取得する関数
def get_recipe_category():
    url = "https://app.rakuten.co.jp/services/api/Recipe/CategoryList/20170426"
    params = {
        "applicationId": RAKUTEN_APP_ID
    }
    response = requests.get(url, params=params)
    if response.status_code == 200:
        data = response.json()
        return data["result"]
    else:
        print("カテゴリの取得に失敗しました。ステータスコード:", response.status_code)
        return {}

# 各カテゴリからレシピを取得する関数
def get_recipes_by_category(category_id):
    params = {
        "applicationId": RAKUTEN_APP_ID,
        "categoryId": category_id
    }
    response = requests.get(RAKUTEN_API_URL, params=params)
    if response.status_code == 200:
        data = response.json()
        if "result" in data:
            return data["result"]
        else:
            return []
    else:
        print(f"カテゴリ {category_id} のレシピ取得に失敗しました。ステータスコード: {response.status_code}")
        return []

# 大カテゴリ、中カテゴリ、小カテゴリのデータを作成する関数
def create_category_data(categories):
    data = []
    parent_dict = {}

    # 大カテゴリ
    for category in categories['large']:
        data.append({'category1': category['categoryId'], 'category2': "", 'category3': "", 'categoryId': category['categoryId'], 'categoryName': category['categoryName']})

    # 中カテゴリ
    for category in categories['medium']:
        data.append({'category1': category['parentCategoryId'], 'category2': category['categoryId'], 'category3': "", 'categoryId': str(category['parentCategoryId']) + "-" + str(category['categoryId']), 'categoryName': category['categoryName']})
        parent_dict[str(category['categoryId'])] = category['parentCategoryId']

    # 小カテゴリ
    for category in categories['small']:
        data.append({'category1': parent_dict[category['parentCategoryId']], 'category2': category['parentCategoryId'], 'category3': category['categoryId'], 'categoryId': parent_dict[category['parentCategoryId']] + "-" + str(category['parentCategoryId']) + "-" + str(category['categoryId']), 'categoryName': category['categoryName']})

    return pd.DataFrame(data)

# 各カテゴリのレシピを取得する関数
def get_all_recipes(category_df):
    all_recipes = []

    count = 1
    for _, row in category_df.iterrows():
        category_id = row["categoryId"]
        category_name = row["categoryName"]
        recipes = get_recipes_by_category(category_id)
        for recipe in recipes:
            recipe_data = recipe.copy()
            recipe_data["categoryId"] = category_id
            recipe_data["categoryName"] = category_name
            all_recipes.append(recipe_data)
            print(count, recipe_data["recipeTitle"])
            count += 1
        time.sleep(1)  # APIリクエストのレートリミットを避けるため
    return all_recipes

# データフレームを保存する関数
def save_recipes_to_tsv(df, filename="rakuten_recipes.tsv"):
    # カラムの順序をカテゴリIDとカテゴリ名が先頭にくるように変更
    columns = ["categoryId", "categoryName"] + [col for col in df.columns if col not in ["categoryId", "categoryName"]]
    df = df[columns]
    df.to_csv(filename, sep='\t', index=False)
    print(f"全てのレシピをデータフレーム化し、{filename} として保存しました。")

# メイン関数
def main():
    # レシピカテゴリのリストを取得
    categories = get_recipe_category()
    # カテゴリデータの作成
    category_df = create_category_data(categories)
    display(category_df)
    # 全てのレシピを取得
    all_recipes = get_all_recipes(category_df)
    # レシピのデータフレーム化
    df = pd.DataFrame(all_recipes)
    display(df)
    # TSVとして保存
    save_recipes_to_tsv(df)

if __name__ == "__main__":
    main()

データ整形

カテゴリの範囲に重複があるので、レシピに重複があります。評価時に学習に使われたデータが入らないようにレシピの重複を消したところ、4550 件のレシピが残りました。
また、材料名("recipeMaterial")に特殊文字が使われる場合があり、学習をより安定化させるために前処理をして材料に関係のない不要な文字は削除するようにしています。生データを見つつ変なものがないかチェックする作業です。以下のコードではまだ消しきれていない部分があるものの、一旦これでデータ整形を完了としました。

import pandas as pd
from datasets import Dataset, DatasetDict
import re

INPUT_FILE_PATH = "rakuten_recipes.tsv"
COLUMNS_TO_SELECT = ["recipeId", "recipeMaterial", "recipeTitle"]
SUBSET_COLUMN = 'recipeId'
PATTERN = r'[^ぁ-んァ-ヶ一-龠a-zA-Z0-90-9\(\)\[\]\{\}\'\,\+\-\_ー ]'
TEXTS_TO_REMOVE = ["Au3000", "u3000", "u202au202a"]

# TSVファイルを読み込む
def load_tsv_file(file_path):
    return pd.read_csv(file_path, sep="\t")

# カラムの限定
def select_columns(df, columns):
    return df[columns]

# レシピの重複削除
def remove_duplicate_recipes(df, subset_column):
    return df.drop_duplicates(subset=subset_column, keep='first')

# 特殊文字を削除する関数
def clean_text(text, pattern, texts_to_remove):
    # 正規表現により不要な文字を削除
    cleaned_text = re.sub(pattern, '', text)
    # 指定したリストの文字列を順に削除
    for remove_text in texts_to_remove:
        cleaned_text = cleaned_text.replace(remove_text, "")
    return cleaned_text

# データフレームに対してクレンジングを実行
def cleanse_dataframe(df, column_name, pattern, texts_to_remove):
    df[column_name] = df[column_name].apply(lambda x: clean_text(x, pattern, texts_to_remove))
    return df

# メイン処理
def main():
    # データの読み込み
    df = load_tsv_file(INPUT_FILE_PATH)
    
    # カラムの限定
    df_limited = select_columns(df, COLUMNS_TO_SELECT)
    
    # レシピの重複削除
    df_recipe = remove_duplicate_recipes(df_limited, SUBSET_COLUMN)
    
    # レシピの材料情報のクレンジング
    df_cleaned = cleanse_dataframe(df_recipe, 'recipeMaterial', PATTERN, TEXTS_TO_REMOVE)
    
    # 最終結果の表示
    print(df_cleaned)

if __name__ == "__main__":
    main()

学習

今回はSFT のライブラリとしてunslothとTRLを用います。unsloth は精度低下なくSFT を少メモリかつ高速に実行できるライブラリになっているそうです。

モデルはunsloth/gemma-2-9b-bnb-4bit を使いました。9B のモデルを4bit で量子化したモデルになっています。学習にはA100 をGPU として利用しました(学習は2 時間程度)。一方で、T4 で十分学習できます。今回は時間短縮のためにオーバースペックなA100 を利用しました。量子化しているとはいえ、9B モデルがGoogle Colab の無料枠で使えるのはすごいですね(量子化した13B 程度のモデルもT4 で動くらしい)。

データはtrain_test_split を用いて学習用、検証用、テスト用で18:1:1に分割しました。

  • 学習用データ: 4095 個
  • 検証用データ: 227 個
  • テスト用データ: 228 個

プロントは下記のようにalpaca プロンプトを基に作成しました。入力には材料名リスト(例: ['卵白', '砂糖', 'アーモンドパウダー', 'ミルクチョコレート'] )、応答部分にレシピ名(例: 簡単なのに美味すぎる!チョコマカロン)が入ります。前述のデータ整形で用意したデータを用います。

# プロンプトのテンプレートを定義
PROMPT = """以下は、タスクを説明する指示と、文脈のある入力の組み合わせです。要求を適切に満たす応答を書きなさい。

### 指示:
あなたは有名な料理研究家です。入力文は料理の材料リストです。材料を使って作ることができる料理名を生成してください。

### 入力:
{}

### 応答:
{}"""

学習パラメータは下記のように設定しています。学習の実装は下記のunsloth が公開しているNotebook を参考に行なっています。unloth は他にも様々なNotebook を公開しており、Google Colab を使用すれば簡単に試せるので、ありがたいですね。
Alpaca + Gemma2 9b Unsloth 2x faster finetuning.ipynb

from trl import SFTTrainer
from transformers import TrainingArguments
from unsloth import is_bfloat16_supported

trainer = SFTTrainer(
    model = model,
    tokenizer = tokenizer,
    train_dataset = dataset_dict['train'],
    eval_dataset = dataset_dict['validation'],
    dataset_text_field = "text",
    max_seq_length = MAX_SEQ_LENGTH,
    dataset_num_proc = 2,
    packing = False,
    args = TrainingArguments(
        per_device_train_batch_size = 2,
        gradient_accumulation_steps = 4,
        warmup_steps = 5,
        num_train_epochs = 2,
        learning_rate = 2e-4,
        fp16 = not is_bfloat16_supported(),
        bf16 = is_bfloat16_supported(),
        logging_steps = 1,
        optim = "adamw_8bit",
        weight_decay = 0.01,
        lr_scheduler_type = "linear",
        seed = 3407,
        output_dir = "outputs",
        report_to = "wandb",
    ),
)

学習を実行すると、loss は下記画像のようになりました。0.6 前後を行ったり来たりという感じでした。
SFT_loss.png

評価

ここでは、学習前後の結果を比較し、学習の効果を簡単に見ていきます。評価方法は、以下の手順で行なっています。

  1. 評価データの正解レシピ名、学習前の生成レシピ名、学習後の生成レシピ名3 つをそれぞれOpenAI のtext-embedding-3-large を用いてembedding する
  2. 正解レシピ名と学習前後のレシピ名のコサイン類似度をそれぞれ計算する
  3. コサイン類似度が高い方が良い回答になっているだろうと想定し、学習前後のコサイン類似度の変化を調べる

その結果、下記のグラフのようになりました。横軸がレシピ名で、縦軸が学習前後のコサイン類似度の変化、ここでは学習によるコサイン類似度の変化スコア(学習後生成レシピ名コサイン類似度 - 学習前生成レシピ名コサイン類似度)になっています。

cosine_similarity_difference_chart.png

グラフを見ると、半分以上のレシピでコサイン類似度が改善しているものの、下がっているレシピ名もあることが分かります。以下では、上位20 件と下位20 件のデータをそれぞれ見て、コサイン類似度が上昇または低下したレシピ名の特徴を簡単に見ていくこととします。

出力結果比較(コサイン類似度が上昇した上位20 件)

結果は下記の表のようになっています。各カラムの説明は以下です。

  • recipeMaterial: 材料リスト(入力データ)
  • recipeTitle: レシピ名(正解データ)
  • recipeTitleBeforeSFT: 学習前の生成レシピ名
  • recipeTitleAfterSFT: 学習後の生成レシピ名
  • cosineSimilarityDifference: 学習前後のコサイン類似度の変化
  • cosineSimilarityBeforeSFT: 学習前のコサイン類似度
  • cosineSimilarityAfterSFT: 学習後のコサイン類似度

まず生成前後をざっくり比較すると、学習前は単なる料理名だったのに対し学習後は正解レシピの雰囲気に近いレシピ名になっている傾向が見て取れます。また、学習前は入力の材料名をそのまま出してしまうことがありましたが、学習後には無くなっていることが分かります。

上から見てみると、上位20 件のレシピ名では、学習前に間違えていた料理名が正解になることでコサイン類似度が上がっている傾向があることが分かりました。一方で、最後の直前の、「カレーの壺」具材たっぷり簡単カレー☆、では学習を通して単なるカレーという料理名に、カレーの壺で簡単カレー、というように説明が追加されることで類似度が上がった例もありました。

recipeMaterial recipeTitle recipeTitleBeforeSFT recipeTitleAfterSFT cosineSimilarityDifference cosineSimilarityBeforeSFT cosineSimilarityAfterSFT recipeUrl
['鶏もも肉', '里芋', '人参', '★醤油', '★酒', '★砂糖', '★和風だしの素', '★水', 'サラダ油', '塩(里芋の塩もみ用)'] 里芋と鶏肉の煮物♪ 鶏もも肉の照り焼き 里芋と鶏肉の煮物 0.451 0.461 0.912 リンク
['米', '玉ねぎ', 'ミックスベジタブル', 'ツナ缶', 'コンソメの素(お湯で溶かしておく)', 'カレー粉', '塩', '胡椒', 'バター'] 炊飯器で簡単♪カレーピラフ カレーライス 炊飯器で簡単!カレーピラフ 0.446 0.536 0.982 リンク
['卵白', '砂糖', 'アーモンドパウダー', 'ミルクチョコレート'] めっちゃ簡単!絶品マカロン チョコレートケーキ 簡単!失敗なし!基本のチョコレートマカロン 0.388 0.455 0.844 リンク
['卵', '牛乳', '砂糖'] 基本のプリン☆お鍋で簡単 オムレツ レンジで簡単!とろとろプリン 0.362 0.325 0.686 リンク
['紅ズワイガニ', '塩'] 美味しく仕上げましょう☆ズワイガニの茹で方 カニの塩焼き 紅ズワイガニの茹で方 0.347 0.426 0.773 リンク
['牛豚合びき肉', '玉ねぎ', '薄力粉', 'じゃがいも', '牛乳', 'バター', '砂糖', 'オリーブオイル', '塩・こしょう', '塗り卵', '(A)', '・白ワイン', '・野菜だし(スープ)', '(固形ブイヨンでも代用可)', '・トマトピューレ', '・ケチャップ', '・塩', '・ナツメグ'] [ル・クルーゼ公式] コテージパイ ハンバーグ 簡単!本格!コロッケ風パイ 0.342 0.245 0.586 リンク
['卵', '水', '道具', '耐熱容器', '冷水用ボウル', 'キッチンペーパー'] ポーチドエッグの作り方*レンジで簡単! オムレツ 電子レンジで簡単!ゆで卵 0.339 0.392 0.731 リンク
['バター', '全粒粉', '砂糖', '牛乳'] 材料4つ!全粒粉クッキー バターケーキ 全粒粉で作るクッキー 0.331 0.424 0.756 リンク
['まいたけ', 'エリンギ', 'ソーセージ', 'サラダ油', 'コンソメ(顆粒)', '塩こしょう'] まいたけエリンギのコンソメ炒め☆ [まいたけのバターソテー, エリンギのソテー, ソーセージの炒め物, サラダ油の炒め物, コンソメ(顆粒)のスープ, 塩こしょうの炒め物] まいたけとエリンギのコンソメ炒め 0.319 0.549 0.868 リンク
['強力粉', '薄力粉', '塩', 'オリーブ油', '熱湯'] 簡単☆柔らか☆自家製トルティーヤ パン 簡単!もちもち!フォカッチャ 0.313 0.291 0.605 リンク

出力結果比較(コサイン類似度が低下した下位20 件)

まず生成前後をざっくり比較すると、学習前は上位20 件と同様に材料リストをそのまま出したり、料理名を羅列したりすることが起きている一方で、学習後では起きていないことが分かります。

一方で、上位20 件の場合とは違い、学習後に料理名が正解データと一致しないことでコサイン類似度が低下してしまう例が多く見られました。

recipeMaterial recipeTitle recipeTitleBeforeSFT recipeTitleAfterSFT cosineSimilarityDifference cosineSimilarityBeforeSFT cosineSimilarityAfterSFT recipeUrl
['にんじん', 'ピーマン', '塩昆布', 'スルメ', 'みりん', 'しょう油'] にんじん・ピーマンの松前漬け風( *´艸) [にんじんのきんぴら, ピーマンの肉詰め, 塩昆布の佃煮, スルメの佃煮, みりんの煮物, しょう油の煮物] にんじんときゅうりの塩昆布和え -0.057 0.581 0.524 リンク
['ヨーグルト', '甘酒', '甘納豆', '水'] 発酵パワーが良いよ! 「甘納豆入り甘酒ヨーグルト」 ヨーグルト甘酒甘納豆水ヨーグルト甘酒甘納豆水ヨーグルト甘酒甘納豆水ヨーグルト甘酒甘納豆水ヨーグルト甘酒甘納豆水 甘酒で作る♪甘納豆のラッシー -0.061 0.587 0.526 リンク
['胡瓜', '砂糖', '塩', '辛子(納豆添付の余ってればお使い下さい)'] 美味しくて教えてもらった胡瓜の漬物 胡瓜の浅漬け 胡瓜の辛子納豆漬け -0.063 0.667 0.604 リンク
['生鮭(甘塩鮭でもOK)', '玉ねぎ', '人参', 'しめじ(他のきのこでもOK)', 'ピーマン', '塩・こしょう', 'マヨネーズ(これはハーフタイプ使用)'] 鮭のマヨネーズホイル焼き~BBQにも! 鮭のマリネ 鮭のジャーマン風炒め -0.064 0.589 0.525 リンク
['豚挽き肉', 'ピーマン', 'ニンジン', 'タマネギ', 'カレールー(市販品)', '水', '★ケチャップ', '★ウスターソース', '塩・こしょう', 'サラダ油', '温泉卵(こつ参照)'] 絶品!ドライカレー カレー 市販のルーで簡単!ピーマンとニンジンのカレー -0.070 0.577 0.507 リンク
['みかん'] みかんの冷凍方法 みかんジュース みかんの皮で作る☆みかんの砂糖漬け -0.072 0.625 0.553 リンク
['トルティーヤの皮', '牛ひき肉', '長ネギ', 'スライスチーズ(とろけるチーズ)', 'サルサソース', '☆おろし生姜', '☆おろしニンニク', '塩・ペッパー', '乾燥パセリ(なくても可)', '油'] 牛ひき肉とネギのエンチラーダス トルティーヤの皮を焼いて、牛ひき肉、長ネギ、スライスチーズ(とろけるチーズ)、サルサソース、おろし生姜、おろしニンニク、塩ペッパー、乾燥パセリ(なくても可)、油を乗せてトルティーヤを包む。 トルティーヤの皮で簡単!チーズケサディーヤ -0.074 0.501 0.427 リンク
['ポップコーン豆', 'サラダ油', 'バター', '塩'] シンプル★やみつき塩バター味のポップコーン ポップコーン ポップコーンの作り方 -0.080 0.716 0.636 リンク
['鶏もも肉', '塩コショウ', '★長ネギ', '★中華スープの素(顆粒)', '★醤油', '★一味唐辛子', 'ごま油'] 簡単!!鶏もも肉の、たっぷりネギごま油かけ❤ 鶏もも肉の照り焼き 鶏肉とネギのスタミナ炒め -0.086 0.654 0.568 リンク
['小麦粉', 'バター', 'きび砂糖', '卵黄', 'しょうが', 'ナツメグ', 'シナモン'] すり下ろし生姜で作るジンジャークッキー クッキー スパイス香る☆スノーボール -0.087 0.479 0.392 リンク
['ご飯', 'たまご', '青ねぎ', '豚肉ほうとう', 'お新香'] ほうとうの残りでおじや たまごかけご飯 簡単!豚肉とたまごのどんぶり -0.090 0.467 0.377 リンク
['そうめん', '*つけ汁*', '麺つゆ(3倍濃縮タイプ)', '氷水', 'しょうがのすりおろし', 'にんにくのすりおろし', '豆板醤', 'すりごま', 'ごま油', 'きざみネギ', 'ラー油(お好みで)'] *そうめんに*ピリ辛中華風つけ汁 そうめんのつけ麺 つけ麺のタレ -0.099 0.724 0.626 リンク
['太めの中華麺', 'ラード', 'ごま油', '豚こま', '★ニンニク', '★一味唐辛子', 'もやし', '水', '〈味噌だれ〉', '味噌', '一味唐辛子', '味の素', '醤油', '酒', '砂糖', '味覇(シャンタンでも)'] 【再現レシピ】蒙古タンメン中本 北極ラーメン ラーメン 味噌だれが美味しい!味噌ラーメン -0.122 0.522 0.400 リンク
['たまご', '●水', '●白だし醤油', '●みりん', '●和風だし'] お弁当に★冷めても美味しい♪卵焼き 卵焼き 白だしで簡単!たまごすいとん -0.130 0.648 0.518 リンク
['ご飯', 'ウインナー', '卵', '刻みネギ', '胡麻油', '鶏ガラ', '醤油', '塩胡椒'] 残り物で❣️チャーハン チャーハン 簡単!ウインナーチャーハン -0.141 0.658 0.517 リンク
['玉ねぎ', '牛の切り落とし(小間切れなど)', '★水', '★しょうゆ', '★料理酒', '★砂糖', '★みりん', '★だしの素', '紅生姜'] すき家風の牛丼!!⭐*゜ [すき焼き, 牛丼, 牛鍋, 牛めし, 牛焼肉, 牛すき焼き, 牛鍋, 牛鍋, 牛鍋, 牛鍋, 牛鍋, 牛鍋, 牛鍋, 牛鍋, 牛鍋, 牛 玉ねぎと牛の切り落としで簡単肉じゃが -0.152 0.545 0.393 リンク
['大根', '干し椎茸', '鶏もも肉', 'にんじん', '☆水', '☆干し椎茸の戻し汁', '☆だしの素', '☆しょうゆ', '☆料理酒', '☆みりん', '☆さとう'] 大根と干し椎茸鶏肉の煮物 大根と干し椎茸の煮物 大根と鶏肉のうま煮 -0.160 0.908 0.748 リンク
['豆腐', '白ネギ', 'きゅうり', '*ザーサイみじん切り', '*豆板醤', '*にんにくみじん切り、生姜みじん切り', '*砂糖', '*醤油、酢', '*ごま油'] 豆腐の中華風サラダ 麻婆豆腐 簡単!本格!ザーサイ入りバンバンジー -0.189 0.520 0.331 リンク
['鯛(30cmくらい)', 'だし用昆布'] 甘鯛の昆布しめ♡ 鯛の昆布締め 鯛の切り方 -0.202 0.718 0.517 リンク
['さつまいも', '塩', '揚げ油'] 観光地に売ってるようなさつまいもチップス! さつまいもチップス さつまいもの揚げ方 -0.233 0.690 0.457 リンク

総評と考察

データを見渡した総評として、学習前はシンプルな料理名を出力したりそもそも指示に従わないことがあったものの、学習後は楽天レシピっぽいレシピ名を出力してくれるようになりました。
FT の目的である回答様式の学習はできたように見受けられます。また、学習前後で不正解の料理名から正解になった例があり、これは材料リストに対し適切にアテンションが張れるようになったことで、精度改善が起きたのでしょうか。

やり残したこと

今回やり残したこととしては以下が挙げられます。

  • データ準備
    • データ前処理の徹底(材料名に関係ない文字を完全には取りきれていない)
  • 学習
    • 学習パラメータの変更による出力変化の調査
  • 評価
    • 材料名に対し生成された料理名がそもそも合っているのか判定
    • 上記含めコサイン類似度でなく、LLM で評価(今回は客観性を持った定量的な評価としたかったためコサイン類似度を採用しました。LLM での評価はプロンプト設計やモデルによって結果が変わってしまうので、今回はやりませんでした)

また、SFT で楽天レシピっぽいレシピ名が作れたので、今度はアライメント(DPO)で更に近い雰囲気にできるかやってみたいです。


以上、記事を読んでいただきありがとうございました。
どなたかの参考になれば幸いです。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?