3
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?

More than 1 year has passed since last update.

[翻訳] Hugging Face transformersにおける前処理

Posted at

Preprocessの翻訳です。

本書は抄訳であり内容の正確性を保証するものではありません。正確な内容に関しては原文を参照ください。

データセットでモデルをトレーニングする前に、期待されるモデル入力フォーマットに前処理を行う必要があります。データがテキスト、画像、音声であろうが、これらはtensorのバッチに変換、組み立てられる必要があります。🤗 Transformersは、モデルのデータの準備に役立つ一連の前処理クラスを提供しています。このチュートリアルでは以下を学びます:

  • テキストでは、テキストをトークンのシーケンスに変換するためにTokenizerを使用し、トークンの数値表現を作成し、tensorを組み立てます。
  • スピーチや音声では、音声波形からシーケンスの特徴量を抽出するためにFeature extractorを使用し、tensorに変換します。
  • 画像入力では、画像をtensorに変換するためにImageProcessorを使用します。
  • マルチモーダルの入力では、トークナイザーと特徴量抽出器や画像プロセッサーを組み合わせるためにProcessorを使用します。

AutoProcessorは、あなたがトークナイザー、画像プロセッサー、特徴量抽出器やプロセッサーを使用していたとしても、常に動作し、使用しているモデルに適切なクラスを自動で選択します。

始める前に、実験するデータセットをロードできるように🤗 Datasetsをインストールします:

pip install datasets

自然言語処理

テキストデータを前処理する主要なツールがtokenizerです。トークナイザーは一連のルールに沿ってテキストをトークンに分割します。トークンは数値、そしてtensorに変換され、これがモデルの入力となります。モデルに必要となる追加の入力はトークナイザーによって追加されます。

事前学習済みモデルを使用しようとしているのであれば、関連づけられている事前学習済みトークナイザーを使用することが重要です。これによって、テキストは事前学習されたコーパスと同じ方法で分割されるようになり、事前学習で用いられたトークンのインデックスを使用するようになります。

AutoTokenizer.from_pretrained()メソッドで事前トレーニング済みトークナイザーをロードするところからスタートします。これは、モデルが事前学習で用いたvocabをダウンロードします:

Python
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")

そして、トークナイザーにテキストを渡します:

Python
encoded_input = tokenizer("Do not meddle in the affairs of wizards, for they are subtle and quick to anger.")
print(encoded_input)
{'input_ids': [101, 2079, 2025, 19960, 10362, 1999, 1996, 3821, 1997, 16657, 1010, 2005, 2027, 2024, 11259, 1998, 4248, 2000, 4963, 1012, 102], 
 'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 
 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]}

トークナイザーは3つの重要なアイテムを持つディクショナリーを返却します:

  • input_idsは、センテンスにおけるそれぞれのトークンに対応するインデックスです。
  • attention_maskは、トークンがアテンションされるかどうかを示します。
  • token_type_idsは、センテンスが複数ある場合、トークンがどのセンテンスに属するのかを示します。

input_idsをデコードすることで入力に戻します:

Python
tokenizer.decode(encoded_input["input_ids"])
'[CLS] Do not meddle in the affairs of wizards, for they are subtle and quick to anger. [SEP]'

見てわかるように、トークナイザーは2つの特殊トークン、CLSSEP(classifierとseparator)をセンテンスに追加しています。全てのモデルで特殊トークンは必要ではありませんが、モデルが必要とする際にはトークナイザーは自動でそれらを追加します。

前処理したいセンテンスが複数ある場合には、トークナイザーにリストを渡します:

Python
batch_sentences = [
    "But what about second breakfast?",
    "Don't think he knows about second breakfast, Pip.",
    "What about elevensies?",
]
encoded_inputs = tokenizer(batch_sentences)
print(encoded_inputs)
{'input_ids': [[101, 1252, 1184, 1164, 1248, 6462, 136, 102], 
               [101, 1790, 112, 189, 1341, 1119, 3520, 1164, 1248, 6462, 117, 21902, 1643, 119, 102], 
               [101, 1327, 1164, 5450, 23434, 136, 102]], 
 'token_type_ids': [[0, 0, 0, 0, 0, 0, 0, 0], 
                    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 
                    [0, 0, 0, 0, 0, 0, 0]], 
 'attention_mask': [[1, 1, 1, 1, 1, 1, 1, 1], 
                    [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], 
                    [1, 1, 1, 1, 1, 1, 1]]}

パディング

センテンスは常に同じ長さというわけではなく、これはモデル入力のtensorが同じ形状を持つ必要があるため問題となることがあります。短いセンテンスに特殊なパディングトークンを追加することで、tensorが長方形になるようにするための戦略がパディングです。

最長のシーケンスにマッチするようにバッチの短いシーケンスにパディングするように、paddingパラメーターをTrueに設定します。

Python
batch_sentences = [
    "But what about second breakfast?",
    "Don't think he knows about second breakfast, Pip.",
    "What about elevensies?",
]
encoded_input = tokenizer(batch_sentences, padding=True)
print(encoded_input)
{'input_ids': [[101, 1252, 1184, 1164, 1248, 6462, 136, 102, 0, 0, 0, 0, 0, 0, 0], 
               [101, 1790, 112, 189, 1341, 1119, 3520, 1164, 1248, 6462, 117, 21902, 1643, 119, 102], 
               [101, 1327, 1164, 5450, 23434, 136, 102, 0, 0, 0, 0, 0, 0, 0, 0]], 
 'token_type_ids': [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 
                    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 
                    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]], 
 'attention_mask': [[1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0], 
                    [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], 
                    [1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0]]}

これで、短い最初と三番目のセンテンスに0のパディングが追加されます。

切り取り

逆の観点では、時にはあるシーケンスがモデルがハンドリングするには長すぎることがあります。この場合、シーケンスを短い長さで切り取る必要があります。

モデルで受け付ける最大長にシーケンスを切り取るために、truncationパラメーターをTrueに設定します:

Python
batch_sentences = [
    "But what about second breakfast?",
    "Don't think he knows about second breakfast, Pip.",
    "What about elevensies?",
]
encoded_input = tokenizer(batch_sentences, padding=True, truncation=True)
print(encoded_input)
{'input_ids': [[101, 1252, 1184, 1164, 1248, 6462, 136, 102, 0, 0, 0, 0, 0, 0, 0], 
               [101, 1790, 112, 189, 1341, 1119, 3520, 1164, 1248, 6462, 117, 21902, 1643, 119, 102], 
               [101, 1327, 1164, 5450, 23434, 136, 102, 0, 0, 0, 0, 0, 0, 0, 0]], 
 'token_type_ids': [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 
                    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 
                    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]], 
 'attention_mask': [[1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0], 
                    [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], 
                    [1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0]]}

より多くのパディングや切り取りの引数の詳細については、Padding and truncationのコンセプトガイドをご覧ください。

tensorの構築

最後に、モデルに入力される実際のtensorを返却するようにトークナイザーに指示します。

return_tensorsパラメーターに、PyTorchならpt、TensorFlowならtfを指定します:

PyTorch

Python
batch_sentences = [
    "But what about second breakfast?",
    "Don't think he knows about second breakfast, Pip.",
    "What about elevensies?",
]
encoded_input = tokenizer(batch_sentences, padding=True, truncation=True, return_tensors="pt")
print(encoded_input)
{'input_ids': tensor([[101, 1252, 1184, 1164, 1248, 6462, 136, 102, 0, 0, 0, 0, 0, 0, 0],
                      [101, 1790, 112, 189, 1341, 1119, 3520, 1164, 1248, 6462, 117, 21902, 1643, 119, 102],
                      [101, 1327, 1164, 5450, 23434, 136, 102, 0, 0, 0, 0, 0, 0, 0, 0]]), 
 'token_type_ids': tensor([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                           [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                           [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]), 
 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0],
                           [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
                           [1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0]])}

TensorFlow

Python
batch_sentences = [
    "But what about second breakfast?",
    "Don't think he knows about second breakfast, Pip.",
    "What about elevensies?",
]
encoded_input = tokenizer(batch_sentences, padding=True, truncation=True, return_tensors="tf")
print(encoded_input)
{'input_ids': <tf.Tensor: shape=(2, 9), dtype=int32, numpy=
array([[101, 1252, 1184, 1164, 1248, 6462, 136, 102, 0, 0, 0, 0, 0, 0, 0],
       [101, 1790, 112, 189, 1341, 1119, 3520, 1164, 1248, 6462, 117, 21902, 1643, 119, 102],
       [101, 1327, 1164, 5450, 23434, 136, 102, 0, 0, 0, 0, 0, 0, 0, 0]],
      dtype=int32)>, 
 'token_type_ids': <tf.Tensor: shape=(2, 9), dtype=int32, numpy=
array([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
       [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
       [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]], dtype=int32)>, 
 'attention_mask': <tf.Tensor: shape=(2, 9), dtype=int32, numpy=
array([[1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0],
       [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
       [1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0]], dtype=int32)>}

音声

音声タスクでは、モデルにデータセットを準備するためにfeature extractorが必要となります。特徴量抽出器は、生の音声データから特徴量を抽出し、tensorに変換するように設計されています。

音声データセットに対してどのように特徴量抽出器を活用できるのかを見るために、MInDS-14データセットをロードします(どのようにデータセットをロードするのかの詳細については🤗 Datasets tutorialをご覧ください):

Python
from datasets import load_dataset, Audio

dataset = load_dataset("PolyAI/minds14", name="en-US", split="train")

入力を確認するためにaudioカラムの最初の要素にアクセスします。audioカラムを呼び出すことで、自動で音声ファイルをロードし、再サンプルします:

Python
dataset[0]["audio"]
{'array': array([ 0.        ,  0.00024414, -0.00024414, ..., -0.00024414,
         0.        ,  0.        ], dtype=float32),
 'path': '/root/.cache/huggingface/datasets/downloads/extracted/f14948e0e84be638dd7943ac36518a4cf3324e8b7aa331c5ab11541518e9368c/en-US~JOINT_ACCOUNT/602ba55abb1e6d0fbce92065.wav',
 'sampling_rate': 8000}

これは3つのアイテムを返却します:

  • arrayは、ロードされたスピーチの信号であり、場合によっては再サンプリングされる1Dの配列です。
  • pathは、音声ファイルへのパスです。
  • sampling_rateは、スピーチ信号で秒間いくつのデータポイントを計測しているのかを示します。

このチュートリアルでは、Wav2Vec2モデルを使用します。モデルカードを参照すると、Wav2Vec2は16kHzでサンプリングされたスピーチオーディオであることがわかります。ご自身の音声データのサンプリングレートが、モデルの事前学習で使用されたデータセットのサンプリングレートと一致することが重要です。データのサンプリングレートが同じでない場合には、データの再サンプリングが必要です。

  1. サンプリングレートを16kHzにアップサンプルするために、🤗 Datasetsのcast_columnメソッドを使用します:

    Python
    dataset = dataset.cast_column("audio", Audio(sampling_rate=16_000))
    
  2. 音声ファイルを再サンプリングするために再度audioカラムを呼び出します:

    Python
    dataset[0]["audio"]
    
    {'array': array([ 2.3443763e-05,  2.1729663e-04,  2.2145823e-04, ...,
         3.8356509e-05, -7.3497440e-06, -2.1754686e-05], dtype=float32),
     'path': '/root/.cache/huggingface/datasets/downloads/extracted/f14948e0e84be638dd7943ac36518a4cf3324e8b7aa331c5ab11541518e9368c/en-US~JOINT_ACCOUNT/602ba55abb1e6d0fbce92065.wav',
     'sampling_rate': 16000}
    

次に、入力を正規化、パディングするために特徴量抽出器をロードします。テキストデータをパディングする際、短いシーケンスには0が追加されます。同じ考え方が音声データにも適用されます。特徴量抽出器は、沈黙を意味する0arrayに追加します。

AutoFeatureExtractor.from_pretrained()で特徴量抽出器をロードします:

Python
from transformers import AutoFeatureExtractor

feature_extractor = AutoFeatureExtractor.from_pretrained("facebook/wav2vec2-base")

特徴量抽出器に音声のarrayを渡します。また、発生する可能性のあるサイレントエラーをよりデバックしやすくするために、特徴量抽出器にsampling_rate引数を追加することをお勧めします。

Python
audio_input = [dataset[0]["audio"]["array"]]
feature_extractor(audio_input, sampling_rate=16000)
{'input_values': [array([ 3.8106556e-04,  2.7506407e-03,  2.8015103e-03, ...,
        5.6335266e-04,  4.6588284e-06, -1.7142107e-04], dtype=float32)]}

トークナイザーと同じように、バッチに対してばらつきのあるシーケンスを取り扱えるようにパディングや切り取りを適用することができます。二つの音声サンプルのシーケンス長を見てみましょう:

Python
dataset[0]["audio"]["array"].shape
(173398,)
dataset[1]["audio"]["array"].shape
(106496,)

音声サンプルが同じ長さになるようにデータセットを前処理する関数を作成します。最大サンプル長を指定すると、特徴量抽出器はマッチするようにシーケンスに対してパディング、切り取りを行います:

Python
def preprocess_function(examples):
    audio_arrays = [x["array"] for x in examples["audio"]]
    inputs = feature_extractor(
        audio_arrays,
        sampling_rate=16000,
        padding=True,
        max_length=100000,
        truncation=True,
    )
    return inputs

データセットの最初の数サンプルにpreprocess_functionを適用します:

Python
processed_dataset = preprocess_function(dataset[:5])

これで、サンプルの長さは指定された最大長にマッチするようになりました。これで、処理されたデータセットをモデルに与えられるようになります!

Python
processed_dataset["input_values"][0].shape
(100000,)
processed_dataset["input_values"][1].shape
(100000,)

コンピュータービジョン

コンピュータービジョンタスクでは、モデルにデータセットを準備するためにimage processorが必要となります。画像処理は、モデルによって期待される入力に画像を変換する数ステップから構成されます。これらのステップには、リサイズ、正規化、カラーチャネル修正、画像からtensorへの変換などが含まれます。

画像処理は、多くの場合ある種の画像拡張を伴います。画像の前処理と画像拡張の両方は画像データを変換しますが、いくつかの目的が異なります:

  • 画像拡張はモデルの過学習を避け、堅牢性を高めるための方法で画像を変更します。明度や色の調整、切り取り、改訂ん、リサイズ、ズームなど、画像をどのように拡張するのかに関してクリエイティブになることができます。しかし、ご自身の拡張処理で画像の意味を変更しないことに気をつけてください。
  • 画像の前処理は画像がモデルの期待する入力にマッチすることを確実にします。コンピュータービジョンモデルをファインチューニングする際、画像は最初にモデルがトレーニングされた際と全く同じように前処理される必要があります。

画像拡張にはお好きなライブラリを活用することができます。画像の前処理では、モデルに関連づけられているImageProcessorを使ってください。

コンピュータービジョンデータセットにどのように画像プロセッサーを使えるのかを見るために、food101データセットをロードします(どのようにデータセットをロードするのかの詳細については🤗 Datasets tutorialをご覧ください):

このデータセットは非常に大きいので、トレーニングスプリットから小規模なサンプルのみをロードするには🤗 Datasetsのsplitパラメーターを使ってください!

Python
from datasets import load_dataset

dataset = load_dataset("food101", split="train[:100]")

次に、🤗 DatasetsのImage機能を用いて画像を見てみます:

Python
dataset[0]["image"]

AutoImageProcessor.from_pretrained()で画像プロセッサーをロードします:

Python
from transformers import AutoImageProcessor

image_processor = AutoImageProcessor.from_pretrained("google/vit-base-patch16-224")

最初に、いくつかの画像拡張処理を追加しましょう。好きなライブラリを活用することができますが、このチュートリアルでは、torchvisionのtransformsモジュールを使用します。他のデータ拡張ライブラリに興味があるのであれば、AlbumentationsKornia notebooksをご覧ください。

  1. ここでは、いくつかの変換処理、RandomResizedCropColorJitterをチェーンするためにComposeを使用します。リサイズではimage_processorの画像サイズ要件を取得できることに注意してください。いくつかのモデルでは、正確な高さ、幅が期待され、他のモデルではshortest_edgeのみが定義されます。

    Python
    from torchvision.transforms import RandomResizedCrop, ColorJitter, Compose
    
    size = (
        image_processor.size["shortest_edge"]
        if "shortest_edge" in image_processor.size
        else (image_processor.size["height"], image_processor.size["width"])
    )
    
    _transforms = Compose([RandomResizedCrop(size), ColorJitter(brightness=0.5, hue=0.5)])
    
  2. このモデルは入力としてpixel_valuesを受け付けます。ImageProcessorは画像の正規化と適切なtensorの作成をケアすることができます。画像のバッチに対して画像拡張と前処理を組み合わせ、pixel_valuesを生成する関数を作成します:

    Python
    def transforms(examples):
        images = [_transforms(img.convert("RGB")) for img in examples["image"]]
        examples["pixel_values"] = image_processor(images, do_resize=False, return_tensors="pt")["pixel_values"]
        return examples
    

    上の例では、画像拡張変換処理ですでに画像をリサイズしており、適切なimage_processorからのsize属性を活用するために、do_resize=Falseを設定しています。画像拡張で画像のリサイズを行っていない場合、このパラメーターはそのままにしてください。デフォルトでImageProcessorはリサイズをハンドリングします。

    拡張変換処理の一部で画像を正規化したいのであれば、image_processor.image_meanimage_processor.image_stdの値を活用してください。

  3. そして、オンザフライで変換処理を適用するために、🤗 Datasetsのset_transformを使用します:

    Python
    dataset.set_transform(transforms)
    
  4. これで、画像にアクセスすると画像プロセッサがpixel_valuesを追加していることに気づくことでしょう。これで、モデルに処理したデータセットを渡せるようになります!

    Python
    dataset[0].keys()
    

変換処理を適用した後の画像がどのようになるのかを以下に示します。画像はランダムに切り取られ、色のプロパティが異なっています。

Python
import numpy as np
import matplotlib.pyplot as plt

img = dataset[0]["pixel_values"]
plt.imshow(img.permute(1, 2, 0))

物体検知、セマンティックセグメンテーション、インスタンスセグメンテーション、パノプティックセグメンテーションのようなタスクにおいて、ImageProcessorは後処理のメソッドを提供します。これらのメソッドは、モデルの生のアウトプットを、領域ボックスやセグメンテーションマップのように意味のある予測結果に変換します。

パディング

あるケースでは、例えば、DETRをファインチューニングする際、モデルはトレーニング時にスケール拡張を適用します。これによって、バッチにおける画像サイズが異なる場合があります。DetrImageProcessorDetrImageProcessor.pad_and_create_pixel_mask()を使用し、バッチ画像を一緒にするためにカスタムのcollate_fnを定義することができます。

Python
def collate_fn(batch):
    pixel_values = [item["pixel_values"] for item in batch]
    encoding = image_processor.pad_and_create_pixel_mask(pixel_values, return_tensors="pt")
    labels = [item["labels"] for item in batch]
    batch = {}
    batch["pixel_values"] = encoding["pixel_values"]
    batch["pixel_mask"] = encoding["pixel_mask"]
    batch["labels"] = labels
    return batch

マルチモーダル

マルチモーダルの入力を含むタスクにおいては、モデルのデータセットを準備するためにprocessorが必要となります。プロセッサーは、トークナイザーや特徴量抽出器のような2つの処理オブジェクトを一緒にまとめることができます。

自動スピーチ認識(ASR)のために、どのようにプロセッサーを活用できるのかを見るために、LJ Speechデータセットをロードします(どのようにデータセットをロードするのかの詳細については🤗 Datasets tutorialをご覧ください):

Python
from datasets import load_dataset

lj_speech = load_dataset("lj_speech", split="train")

ASRでは、主にaudiotextにフォーカスするので他のカラムを削除することができます:

Python
lj_speech = lj_speech.map(remove_columns=["file", "id", "normalized_text"])

それでは、audiotextカラムを見てみましょう:

Python
lj_speech[0]["audio"]
{'array': array([-7.3242188e-04, -7.6293945e-04, -6.4086914e-04, ...,
         7.3242188e-04,  2.1362305e-04,  6.1035156e-05], dtype=float32),
 'path': '/root/.cache/huggingface/datasets/downloads/extracted/917ece08c95cf0c4115e45294e3cd0dee724a1165b7fc11798369308a465bd26/LJSpeech-1.1/wavs/LJ001-0001.wav',
 'sampling_rate': 22050}

lj_speech[0]["text"]
'Printing, in the only sense with which we are at present concerned, differs from most if not from all the arts and crafts represented in the Exhibition'

モデルの事前学習で使用されたデータセットのサンプリングレートとご自身の音声データセットのサンプリングレートが一致するように、常に再サンプルを行うことを覚えておいてください!

Python
lj_speech = lj_speech.cast_column("audio", Audio(sampling_rate=16_000))

AutoProcessor.from_pretrained()でプロセッサーをロードします:

Python
from transformers import AutoProcessor

processor = AutoProcessor.from_pretrained("facebook/wav2vec2-base-960h")
  1. arrayに含まれる音声データをinput_valuesに処理し、textlabelsにトークナイズする関数を作成します。これらはモデルの入力になります:

    Python
    def prepare_dataset(example):
    audio = example["audio"]
    
        example.update(processor(audio=audio["array"], text=example["text"], sampling_rate=16000))
    
        return example
    
  2. サンプルにprepare_dataset関数を適用します:

    Python
    prepare_dataset(lj_speech[0])
    

プロセッサーはinput_valueslabelsを追加し、サンプリングレートは適切に16kHzにダウンサンプルします。これで、処理されたデータセットをモデルに与えられるようになりました!

3
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
3
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?