10
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

文章生成 AI のファインチューニングしてみた

Last updated at Posted at 2024-08-31

はじめに

以前に、Transformers 、Gemma などを使って、生成 AI プログラミングを試してみました。

生成 AI プログラムを試してみた #LLM - Qiita

LLM(大規模言語モデル)で質問に文章で回答させることができました。
ただし公開されている作成済のモデルを使って・・です。これではモデルを作成したときに学習させたデータに基づいた内容しか回答できません。
そこで、以前に RAG(検索拡張生成)を試してみました。

説明4.png

作成済のモデルにない内容を生成させるのに、ファインチューニング(精密調整)があります。

説明3.png

これを試したいと思います。

ファインチューニングしてみる

ファインチューニングの基本的な流れは以下の通り

①ベースになるモデルを準備する。併せてトークナイザを準備する
②学習データを準備する
③学習を実行する
④学習して作成されたモデルを保存する

実行環境を用意する

AI プログラムの実行環境は、高速な計算するために大きなメモリや GPU を使います。そのため高額なマシンが必要になります。
高機能なマシンを時間利用できるクラウドサービスが用意されています。
これまで Google Colab を使ってきました。便利なサービスですが不便なところもあります。
そこで、GPU 搭載+Python+VS Code の開発環境を、Google Cloud の Compute Engine サービスで用意してみました。

GPU 搭載+Python+VS Code の開発環境を作ってみた #VSCode - Qiita

Trainer でファインチューニングする

ファインチューニングを実装するために便利な機能が、Transformers ライブラリの Trainer クラスに用意されています。

必要なライブラリをインストールする

Transformers ライブラリを使うのでインストールします。

$ pip install transformers accelerate sentencepiece 

さらに Datasets ライブラリを使います。

$ pip install datasets

言語モデルを準備する

文章生成するときと同様にモデルとトークナイザを準備します。

import transformers
import torch

# モデルとトークナイザの準備
model = transformers.AutoModelForCausalLM.from_pretrained(
    "cyberagent/open-calm-large",
    torch_dtype=torch.bfloat16
)
tokenizer = transformers.AutoTokenizer.from_pretrained(
    "cyberagent/open-calm-large"
)

今回は cyberagent/open-calm-large を使ってみます。↑

チューニング前のモデルで生成してみる

まず、チューニングする前のモデルを使って文章生成してみます。

prompt = "休日の朝は"

input = tokenizer(
    prompt,
    return_tensors="pt"
)

outputs = model.generate(
    **input,
    max_length=64,
    do_sample=True,
    num_return_sequences=5
)

for i in range(len(outputs)):
    print("%d:" % (i+1))
    output = tokenizer.decode(outputs[i], skip_special_tokens=True)
    print(output)

実行結果↓

1:
休日の朝はなかなか起きられず...結局、そのまま出勤してしまうことも。そして、お昼も「眠い...」と弱音を吐いてしまうことが多くなり、夕方からは、いつも以上に疲れて仕事に熱が入る...という悪循環に! そんな日々が3か月くらい続いた頃、ある方に言われたんです。その方が言う
2:
休日の朝は「朝食はパンで十分だよ!」と言う人が多いですが、朝食は食べない。朝ごはんを食べないと、夜に眠れないからと、食欲が抑えられていることもある。食欲を抑える作用があるのは、カフェイン。カフェインの摂り過ぎは胃や膵臓の負担になるので、食前に飲むコーヒーからカフェイン
3:
休日の朝はウォーキングがてら買い物。昼前に起きて、ゆっくり準備してから、夕方から、ちょっとゆっくりして晩ご飯の買い出し。そのあとは、お風呂。それから、少しのんびりしてから、歯磨きとベッドへ。寝るのが午前0時過ぎてしまいましたが、もう寝ます。
4:
休日の朝は、この「鶏と野菜のスープカレー」を食べて、朝食を済ませています。「スープカレー」といってもお店で売っているものではなく、“だし専門店”です。先日(と言っても先週ですが...)、久しぶりに「スープカレー」を食べに来ました。 場所は、池袋の「スパイスランド」です。 カレーは
5:
休日の朝は早く起きること。まずは、自分がリラックス出来る環境に身を置く事ですね。そして、夜寝る前、テレビをいきなり見るのではなく、一度、音楽を聴いたり、アロマテラピーしたり、好きなモノを1ページずつ読む事から始めてみてはいかが

学習データを用意する

学習データを用意します。このために、Transformers に Datasets ライブラリが用意されています。

参考:HuggingFace Datasets の使い方|npaka

学習データを Hugging Face のリポジトリから入手することにします。

Hugging Face – The AI community building the future.

import datasets

# データセットを用意
datadic = datasets.load_dataset("federerjiang/dialect.osaka")
dataset = datadic['train']

if len(dataset) > 1000: dataset = dataset.take(1000)  # データ量を減らす

federerjiang/dialect.osaka を選びました。↑

federerjiang/dialect.osaka · Datasets at Hugging Face

読込したデータセットを確認してみます。↓

# データセットを確認
print(dataset)

# pandas 形式に変換
dataset.set_format(type="pandas")
print(dataset[:])
Dataset({
    features: ['sentence', 'audio'],
    num_rows: 1300
})

sentence                                                                              audio
できるだけスマートフォンひとつで身の回りのことみんな片付けようとしてるみたいやで    {'path': 'osaka1300_0001.wav', 'array': [-3.46...
最近下宿し始めたからちゃうか                                                      {'path': 'osaka1300_0002.wav', 'array': [7.677...
ほなあんたの紹介文いらんわ                                                          {'path': 'osaka1300_0003.wav', 'array': [8.283...
ポン酢とかソースの種類なんかこれでもかってゆうぐらいあんねん                      {'path': 'osaka1300_1296.wav', 'array': [0.001...

チューニングに使えそうな文章が sentence 項目にセットされています。↑

チューニングを実行する

トレイナを準備する

チューニングします。このために、Transformers に Trainer クラスが用意されています。

参考:huggingface/transformersのTrainerの使い方と挙動 #bert - Qiita

Trainer クラスを使ってファインチューニングする基本的なコードは以下の通り。

# トレイナの準備
trainer = transformers.Trainer(
    model=model,
    data_collator=transformers.DataCollatorForLanguageModeling(
        tokenizer,
        mlm=False
    ),
    args=transformers.TrainingArguments(
        output_dir="./output",
        num_train_epochs=5,
        per_device_eval_batch_size=1,
    ),
    train_dataset=dataset
)

# トレーニングする
trainer.train()

実行するとエラーになりました。↓

例外が発生しました: ValueError
No columns in the dataset match the model's forward method signature. The following columns have been ignored: [audio, sentence]. Please check the dataset and model. You may need to set `remove_unused_columns=False` in `TrainingArguments`.

(対応①)データセットを加工する

トレイナの data_collator にコレイタ(照合者)を指定しています。このコレイタが要求する通りに、データセットを予め処理しておく必要あるようです。

参考:Hugging FaceのDatasetsとTransformersで作ったテキスト分類モデルをSHAPで可視化してみました。 - CCCMKホールディングス TECH LABの Tech Blog

DataCollatorForLanguageModeling を使うなら、チューニングに使いたい列の内容をトークン化して input_ids 列にセットします。

# データセットのトークン化
tokenized_dataset = dataset.map(
    lambda example: 
        tokenizer(example['sentence'], truncation=True),
    batched=True
)

# トレイナの準備
trainer = transformers.Trainer(
    (中略)
    train_dataset=tokenized_dataset  # 予め処理したデータセット
)

(対応②)コレイタで処理する

コレイタが要求する通りにデータセットをトレイナに渡す前に処理しましたが、この処理はコレイタがすればよくないでしょうか。

参考:データ照合者

加工しないデータセットをトレイナに渡して、コレイタで処理してみます。

参考:huggingfaceのTrainerクラスを使えばFineTuningの学習コードがスッキリ書けてめちゃくちゃ便利です #自然言語処理 - Qiita

# コレイタを準備
class CustomCollator(transformers.DataCollatorForLanguageModeling):
    def __call__(self, features):
        # トークン化
        batch = self.tokenizer(
            [feature['sentence'] for feature in features],
            padding=True, truncation=True,
            return_tensors="pt",
        )
        # ラベルの生成
        labels = batch['input_ids'].clone()
        labels[labels == self.tokenizer.pad_token_id] = -100
        batch['labels'] = labels
        return batch
        
# トレイナの準備
trainer = transformers.Trainer(
    model=model,
    data_collator=CustomCollator(  # 準備したコレイタを使う
        tokenizer,
        mlm=False
    ),
    args=transformers.TrainingArguments(
        output_dir="./output",
        num_train_epochs=5,
        per_device_eval_batch_size=1,
        remove_unused_columns=False  # データセットの列がコレイタに渡されるようにする
    ),
    train_dataset=dataset  # 加工しないデータセット
)

(対応③)SFTTrainer を使う

Trainer クラスを拡張した SFTTrainer があるようです。これを使ってみます。

参考:Google Colab で SFTTrainer によるLLMのフルパラメータのファインチューニングを試す|npaka

追加でライブラリをインストールします。

$ pip install trl

Trainer の代わりに SFTTrainer クラスでトレイナを準備します

import trl

# トレイナの準備
trainer = trl.SFTTrainer(  # SFTTrainer を使う
    model=model,
    data_collator=transformers.DataCollatorForLanguageModeling(  # 既製のコレイタを使う
        tokenizer,
        mlm=False
    ),
    args=transformers.TrainingArguments(
        output_dir="./output",
        num_train_epochs=5,
        per_device_eval_batch_size=1,
    ),
    train_dataset=dataset,
    dataset_text_field="sentence"  # 学習に使用する項目を指定
)

作成されたモデルを保存する

トレイナの設定で output_dir を指定してあると、作成されたモデルの内容が保存されます。
上記のチューニングすると、指定したディレクトリは以下のようになりました。

./output/
  ├─ checkpoint-500/
  ├─ checkpoint-625/
  └─ runs
     └─ Sep01_00-00-00_mycompyter

ここでは 500 ステップごとに記録されます。作業が中断してしまったとき、記録できているステップから再開できるようです。
最後のチェックポイント、ここでは checkpoint-625 ディレクトリに、作成が完了したモデルの内容が記録されています。ディレクトリにつけられる番号は、対象のモデルや学習データの量によって変わります。このディレクトリを参照すればいいのですが、ディレクトリ名を予め決められないのが困ります。

場所と名前を指定してモデルを保存することができます。↓

# トレーニングする
trainer.train()

# 保存する
trainer.save_model("./trained_model")

チューニングしたモデルで生成してみる

チューニングしたモデルを使って文章生成してみます。

# モデルとトークナイザの準備
model = transformers.AutoModelForCausalLM.from_pretrained(
    "./trained_model"
)
tokenizer = transformers.AutoTokenizer.from_pretrained(
    "cyberagent/open-calm-large"
)

prompt = "休日の朝は"

(以下略)

実行結果↓

1:
休日の朝は娘とモーニングやけん早起きしてん。めっちゃ楽しみ〜😄でも、ちょっと雨降りそうやから、朝ゆっくりできへんね。ほな、頑張って行ってきまぁ〜す。( ́・ω・`) お前らは、どない思てんねん、今日は。今日は日曜日や
2:
休日の朝はパン焼かへんの?なんか、最近パン作りに興味わいてきたわ。オーブンも、スチームオーブンで十分。でも、今のオーブンは、パン専用やねん。ちゃんと温まってないのが出来へんから、電気で焼く方がええんとちゃうか。オーブンだけ買う
3:
休日の朝はゆっくりしたいなぁ。お家でブランチとかやってみたいけど、なかなか時間がなくてなぁ。朝ごはんは大体がおんなじやねんけど、違うもんも食べたいねん。おにぎりとかパンとかピザとか、最近のお気に入りは、グラーノ工房っていうとこの炒めごはん専門店なんやでー。
4:
休日の朝はジョギングするのが楽しみのひとつ。あ、夜走るん嫌いじゃないぜ。昼間走るってゆうのもあんねん。なんちゅうか、ほんま、自然の中走るんがなんかホンマにええねん。やっぱ夏やったら太陽の日差しがまぶしいわ。走るとめっちゃ冷えるねん。走った直後は
5:
休日の朝はゆっくりコーヒーでも飲んでのんびりしていかんね。大阪人のソウルフードは、ほんまにコーヒー(コーヒー飲むんも大阪独特やで)やで。このあとは、難波(なんば)やで。でわでわ、バイチャリスーパーセブンでや!大阪の人の

チューニングされた文章になっていることが分かります。↑

学習データを変えてみる

学習データに使えるテキストファイルを入手します。

matsuvr/OjousamaTalkScriptDataset: 一般人とお嬢様の会話データセットです。MIT License

ウェブサイトからダウンロードします。

$ wget -P ./input https://raw.githubusercontent.com/matsuvr/OjousamaTalkScriptDataset/main/ojousamatalkscript200.csv

テキストファイルの内容を確認します。↓

prompt,completion
おはよう,おはようございます。素敵な朝ですね
朝ごはんは何が好き?,朝はお米とお味噌汁をいただきますわ
ラブレターを書いたことはありますか,わたくしまだ恋をしたことがありませんの。あこがれますわ
猫派ですか犬派ですか,うちでは大きなゴールデンレトリバーがおりますわ
好きなアイドルはいますか,両親があまりテレビを見せてくださらないんですの。かわいらしいお洋服には憧れますわ

テキストファイルを読込してデータセットを準備します。

参考:Huggingface Datasets 入門 (2) - データセットの読み込み|npaka

# データセットを用意
datadic = datasets.load_dataset("csv", data_files=["./input/ojousamatalkscript200.csv"])  #ローカルのファイルを読込
dataset = datadic['train']

if len(dataset) > 1000: dataset = dataset.take(1000)  # データ量を減らす
Dataset({
    features: ['prompt', 'completion'],
    num_rows: 202
})

prompt                                completion
おはよう                              おはようございます素敵な朝ですね
朝ごはんは何が好き                  朝はお米とお味噌汁をいただきますわ
ラブレターを書いたことはありますか    わたくしまだ恋をしたことがありませんのあこがれますわ
猫派ですか犬派ですか                  うちでは大きなゴールデンレトリバーがおりますわ
好きなアイドルはいますか              両親があまりテレビを見せてくださらないんですのかわいらしいお洋服には憧れますわ

Hugging Face のリポジトリから入手したのと同様になっています。チューニングに使えそうな文章が completion 項目にセットされています。↑

トレーニングし直します。

チューニングし直したモデルで文章生成してみます。

実行結果↓

1:
休日の朝はゆっくりしていますわ。私は基本的に外出は好きなのですが、人と会う場合は気をつけた方がよろしいと思ひますので、なるべく自宅で過ごすことにしておりますの。そういや、今朝は早起きしてブログの編集をしに外に出かけたのでしたわ。まだ7時前ですが、もうそろそろ家を出たい気分なのですわよ
2:
休日の朝は早起きしたい!...ということで、昨夜は早めに寝ましたわ~。朝起きてみると...今日は特に予定なし、休日、のんびりいこうと思いますわね~。この日記を書くのに、最近、ご機嫌に起きておりますわよ~。今、お布団のしたに、こんな
3:
休日の朝は子供たちの面倒をみるのが日課ですわ。わたくしも、一緒にゴロゴロしたいのだけれどもわしら子供らはなかなかじっとしていられないのですわ。ですから、わたくし、今日は何をやろうかと考えていましたのやで!わたくし、子供をほったらかしてゴロゴロしたいですわ
4:
休日の朝は、いつも映画を見ていますわ。映画が好きなので、見たら必ず1つは感想をブログに投稿していますわね。今日は、その1つですわ。「レ・ミゼラブル」ですわ。この作品は、3回も映画館で見ておりますわ。私、この作品がとても好きなのでわ
5:
休日の朝は、主人と子供たちの朝食準備係なので、楽ですわ。我が家はお料理が得意ではないので、お手伝いさせてくださいまし。おはようございます。今朝も冷えますわね~。私は、冬が大好き~!!!寒いときは、あったか~いものを食べてすごしたいわわ~!!!

指示チューニングしてみる

前述の文章生成は、プロンプトで指定した文で始まる文章が生成されました。

チャット形式の生成 AI サービスのように、指示する文章をプロンプトに指定したときは、どうなるのでしょうか。

prompt = "休日の朝は何をするといいでしょうか。"

input = tokenizer(
    prompt,
    return_tensors="pt"
).to(model.device)

outputs = model.generate(
    **input,
    max_length=64,
    do_sample=True,
)

output = tokenizer.decode(outputs[0], skip_special_tokens=True)
print(output)

実行結果↓

休日の朝は何をするといいでしょうか。
それは、朝起きてから、お風呂に入って、それから寝るまでです。
毎日入浴してたら、体も熱くなるし、免疫力なども落ちてくるだろうし、
毎日入浴しても熱くなったりもしてきそうですね。
あとあと考えると、朝起きてから寝つきもよくなったな、と思います。

チューニングされていない状態のモデルは、このように生成されるようです。
チャット形式の生成 AI サービスのように指示に従って回答する内容を生成するには、どうしたらいいでしょうか。

参考:Instruction Tuningにより対話性能を向上させた3.6B日本語言語モデルを公開します

指示チューニング(instruction tuning)することで、生成 AI から適切な回答を得られるようになります。指示と回答をセットにしたデータを学習させます。

参考:Instruction Tuning – 【AI・機械学習用語集】

以前に Gemma を使ってみたとき google/gemma-2b-it を使いました。「it」がついているのは、指示チューニングされているモデルのようです。

指示チューニングしてみましょう。

言語モデルを準備する

通常のチューニングするときと同様にモデルとトークナイザを準備します。

import transformers
import torch

# モデルとトークナイザの準備
model = transformers.AutoModelForCausalLM.from_pretrained(
    "cyberagent/open-calm-small",
    torch_dtype=torch.bfloat16
)
tokenizer = transformers.AutoTokenizer.from_pretrained(
    "cyberagent/open-calm-small"
)

今回は cyberagent/open-calm-small を使います。↑

学習データを用意する

学習データを用意します。

Hugging Face のリポジトリから入手します。↓

import datasets

# データセットを用意
datadic = datasets.load_dataset("kunishou/databricks-dolly-15k-ja")
dataset = datadic['train']

kunishou/databricks-dolly-15k-ja を選びました。↑

kunishou/databricks-dolly-15k-ja · Datasets at Hugging Face

Dataset({
    features: ['output', 'input', 'index', 'category', 'instruction'],
    num_rows: 15015
})

output                                               input                                                instruction
ヴァージン・オーストラリア航空は、2000年8月31日...   ヴァージン・オーストラリア航空(Virgin Australia...  ヴァージン・オーストラリア航空はいつから運航を開始したのですか?
イコクエイラクブカ                                   (なし)                                               魚の種類はどっち?イコクエイラクブカとロープ
ラクダは、長時間にわたってエネルギーと水分で満た...  (なし)                                               ラクダはなぜ水なしで長く生きられるのか?
三女の名前はアリス                                   (なし)                                               アリスの両親には3人の娘がいる:エイミー、ジェシー、そして三女の名前は?
いいえ。ステイルメイトとは、引き分けた状態のこと...  ステイルメイトとは、チェスにおいて、手番が回って...  ステイルメイトの時に、私の方が多くの駒を持っていたら、私の勝ちですか?

instructionoutput 項目の内容がチューニングに使えそうです。input 項目はデータによってセットされていたりしなかったりします。↑

dataset = dataset.filter(lambda data: data['input'] == "")  # 対象を絞る

if len(dataset) > 5000: dataset = dataset.take(5000)  # データ量を減らす

今回は input 項目が空のデータだけ使うことにします。↑

チューニングを実行する

テンプレートを用意する

以下のテンプレートを用意します。

    以下はタスクを記述した指示です。指示を適切に満たす応答を書きなさい。\n
    ### 指示:\n
    {instruction}\n
    ### 応答:\n
    {output}

データセットを加工する(対応①)

トレイナに渡すデータを、上記のテンプレートで加工して渡します。

# テンプレートを準備
template = (
    "以下はタスクを説明する指示です。要求を適切に満たす応答を書きなさい。\n"
    "### 指示:\n{instruction}\n"
    "### 応答:\n{output}\n"
    + tokenizer.eos_token
)

# データセットのテンプレート化とトークン化
tokenized_dataset = dataset.map(
    lambda example: 
        tokenizer(template.format_map(example), truncation=True),
    batched=False
)

(以下略)

コレイタで処理する(対応②)

データセットは加工しないで、コレイタで処理しても構いません。

# テンプレートを準備
template = (
    "以下はタスクを説明する指示です。要求を適切に満たす応答を書きなさい。\n"
    "### 指示:\n{instruction}\n"
    "### 応答:\n{output}\n"
    + tokenizer.eos_token
)

# コレイタを準備
class CustomCollator(transformers.DataCollatorForLanguageModeling):
    def __call__(self, features):
        # トークン化
        batch = self.tokenizer(
            # テンプレート化
            [template.format_map(feature) for feature in features],
            padding=True, truncation=True,
            return_tensors="pt",
        )
        # ラベルの生成
        labels = batch["input_ids"].clone()
        labels[labels == self.tokenizer.pad_token_id] = -100
        batch['labels'] = labels
        return batch

(以下略)

SFTTrainer を使う(対応③)

Trainer の代わりに SFTTrainer クラスでトレイナを準備することもできます。

参考:Google Colab で SFTTrainer によるLLMのフルパラメータの指示チューニングを試す|npaka

# プロンプトフォーマットを準備
def prompt_format(example):
    output = []
    for i in range(len(example['instruction'])):
        text = (
            "以下はタスクを説明する指示です。要求を適切に満たす応答を書きなさい。\n"
            f"### 指示:\n{example['instruction'][i]}\n"
            f"### 応答:\n{example['output'][i]}\n"
            + tokenizer.eos_token
        )
        output.append(text)
    return output

response_template = "### 応答:\n"

import trl

# トレイナの準備
trainer = trl.SFTTrainer(
    model=model,
    data_collator=trl.DataCollatorForCompletionOnlyLM(
        response_template,
        tokenizer=tokenizer
    ),
    args=transformers.TrainingArguments(
        output_dir="./output",
        num_train_epochs=5,
        per_device_eval_batch_size=1,
    ),
    train_dataset=dataset,
    formatting_func=prompt_format
)

チューニングしたモデルで生成してみる

チューニングしたモデルを使って文章生成してみます。

model = transformers.AutoModelForCausalLM.from_pretrained(  # チューニング後のモデルを使う
    "./trained_model"
)
tokenizer = transformers.AutoTokenizer.from_pretrained(
    "cyberagent/open-calm-small"
)

prompt = "休日の朝は何をするといいでしょうか。"

(以下略)

実行結果↓

休日の朝は何をするといいでしょうか。
それとも仕事帰りに飲みに行ったり、趣味の何かをするいいのでしょうか。
お仕事を休みたいのですが、どうしたらいいのでしょうか。
仕事自体は好きで頑張ってます。ただ休みの日はどうしても、子供といる時間がダラダラとしてしまうのですが、お仕事を頑張りたいとは思えません。

チューニングの効果はあったでしょうか。↑

プロンプトをテンプレートに合わせる

指示チューニングしたモデルで生成するときは、チューニングに使用したテンプレートにプロンプトを合わせるようです。

input_text = "休日の朝は何をするといいでしょうか。"

prompt = (
    "以下はタスクを説明する指示です。要求を適切に満たす応答を書きなさい。\n"
    f"### 指示:\n{input_text}\n"
    "### 応答:\n"
)

実行結果↓

以下はタスクを説明する指示です。要求を適切に満たす応答を書きなさい。
### 指示:
休日の朝は何をするといいでしょうか。
### 応答:
午前中に仕事を終わらせて、午前中に用事がなくて仕事を片付けたら、午後から仕事をしましょう。

内容に疑問ありますが、指示に対する回答らしくなりました。

何度か実行してみます。↓

### 応答:
日の出や星を眺めましょう。
### 応答:
散歩です。

モデルをカスタマイズする方法

作成済のモデルをカスタマイズする方法について、用語を整理してみました。

  • 軽量化

    • 量子化(quantize)
      パラメータを低精度の形式に変換してモデルのサイズとメモリ使用量を抑える

    • 蒸留(distilation)
      学習済のモデルを使用して、より軽量の新しいモデルを作成する

  • アラインメント(alignment)
    LLM の出力を人間の意図や倫理原則に合わせて調整する
  • RAG(retrieval-augmented generation(検索拡張生成)
    事前学習やチューニングに含まれない情報を参照して回答する
  • ファンクションコーリング(function calling)
    外部システムの機能を呼出して連携する
  • プロンプトエンジニアリング
    目的に対して適切な回答が得られるようプロンプトを工夫する
10
7
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
10
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?