8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Lancers(ランサーズ)Advent Calendar 2023

Day 17

ランサーズのデータをOpenAIのAPIでテキストマイニングしてみた話

Last updated at Posted at 2023-12-20

この記事は、Lancers(ランサーズ) Advent Calendar 2023 の17日目の記事です。

本記事では、ランサーズのデータに対して、OpenAIのAPIを使って簡単なテキストマイニングをしてみたので紹介します。

事前知識

テキストマイニング?OpenAIのAPI?って人でも分かりやすいように、本記事に必要な事前知識をChatGPTに教えてもらったものをまとめたので、皆様の理解の助けになればと思います。

テキストマイニングとは

私「テキストマイニングって何ですか?」

ChatGPT「テキストマイニングとは、大量の書かれた言葉(例えばSNSの投稿、ニュース記事、フォーラムのディスカッションなど)から、重要なパターンや傾向を見つけ出す分析手法です。このプロセスでは、コンピューターがテキストデータを自動で読み取り、特定のトピックや感情、関連するキーワードを特定します。その結果、ビジネスや研究における意思決定や市場動向の理解に役立てることができます。」

私「一言で言うと?」

ChatGPT「テキストマイニングは、大量の文章から重要なポイントやトレンドを見つけ出す方法です。」

私「どんなものがあるの?」

ChatGPT「テキストマイニングでよく使われる手法には以下のようなものがあります。」

  • ワードクラウド: 文書内の単語の出現頻度を視覚的に表現する
  • トピックモデリング: 文書から隠れたトピックを発見する
  • 感情分析: テキストの感情的トーンを識別する
  • テキスト分類: テキストを特定のカテゴリーに分ける
  • エンティティ抽出: 具体的な名前や場所をテキストから抽出する

本記事では、ワードクラウドをOpenAIのAPIを使って作ってみようと思います。

ワードクラウドとは

私「集めたデータからワードクラウドを作成する一般的な流れを教えて。」

ChatGPT「ワードクラウドを作成する際の一般的なテキストマイニングの流れは以下の通りです。」

  1. テキストの前処理: 不要な文字を削除し、文章を単語に分割して、一般的な単語(ストップワード)を取り除きます
  2. 頻度の計算: 残った単語の出現頻度を計算します
  3. ワードクラウドの生成: 単語の頻度に基づいて、それぞれを異なるサイズで視覚的に表示します

本記事では、テキストの前処理にOpenAIのAPIを使うこで、文章を単語に分割する形態素解析器などのハードルが高い部分を除きつつ、単語だけでは分からない文章の意味や表現を扱ったワードクラウドっぽいものを作ります。

OpenAIのAPIとは

私「OpenAIのAPIとは?」

ChatGPT「ChatGPTとOpenAIのAPIは似ていると思われがちですが、OpenAIのAPIは、GPT-3.5, GPT-4, DALL·E 3, GPTsなど、ChatGPTで提供されている機能の他にも、多くの独自機能を持っています。例えば、OpenAIのAPIでは以下のような機能が提供されています。」

  • Embeddingモデル: テキストを機械が解析しやすい形式に変換します
  • Fine-tuning: 特定のテキストデータに基づいて、モデルを特化させることができます
  • Moderations: OpenAIが設定するコンテンツポリシーに違反しているかどうかを判断するツールです

本記事では

  • 文章から情報を抽出 → GPT-3.5
  • 抽出した情報をグループ分け → Ada (Embeddingモデル)
  • グループ分けした情報から要約文を生成 → GPT-3.5

を使います。

本題

「ランサーズの仕事の依頼文章って、どんなことを書けば受注されやすいんだろう?」

上記のテーマで、OpenAIのAPIを使った分析をしてみました。

実行環境

環境構築は以下のDockerfileを利用し、使用したライブラリはrequirememts.txtになります。

FROM python:3.9

WORKDIR /work

COPY requirements.txt ./
RUN pip install --upgrade pip
RUN pip install --no-cache-dir -r requirements.txt
requirements.txt
openai==1.3.7
sckit-learn==1.3.2
pandas==2.1.3
numpy==1.26.2
wordcloud==1.9.2
matplotlib==3.8.2
japanize-matplotlib==1.1.3

データの準備

ランサーズでは仕事を発注する際、仕事の内容や料金などを含む依頼文章を公開することで、仕事を受けたいと思った方から見積もり書(このくらいの金額・納期でこんなことができますという内容の提案文章)を募集することができるプロジェクト方式と呼ばれる発注方法があります。1

テーマにおける「受注されやすい」=「依頼文章を公開してから見積もり書が来るまで早い」として、依頼文章を公開してから24時間以内に見積もり書が集まっているものを「受注されやすい依頼文章」とします。
「受注されやすい依頼文章」のデータを以下の条件で収集します。

データの条件

  • 2023年中に作成された依頼文章
  • 公開から24時間以内に1つ以上の見積もり書が集まっている
  • 募集期間:7日間以上
  • 依頼方式:プロジェクト方式
  • ジャンル:記事作成・ブログ記事・体験談
  • 予算:5,000~10,000円

以上の条件で101件のデータが集まりました。
(本格的なデータ分析にはデータが少なすぎるので条件の緩和を考えますが、今回は実験なので小規模なデータで行います)

データの前処理

テーマが「依頼文章にどんなことを書けば」なので、金額やジャンルといった文章とは関係ない情報は除きたいです。
今回のデータは文章の形式が決まっているので、正規表現を使って不要な部分をある程度は除去しました。

依頼文章に含まれる受注されやすい理由を抽出

データが整備が終わったので、これでやっと分析が始められます!
テキストデータから要素を抽出してくる(ワードクラウドにおける単語分割)工程は、OpenAIのAPIを使いGPT-3.5に依頼文章から受注されやすい理由を生成することで実現します。
以下の実行コードで、「受注されやすい理由」を受注する側として見積もり書を応募したくなる理由として考えてもらうことにしました。

実行コード
from openai import OpenAI
import pandas as pd

# OpenAIのクライアントの準備
client = OpenAI()

# プロンプト
SYSTEM_TEMPLATE = "あなたは優秀なフリーランスのライターです。あなたは、たくさんある依頼文章の中からあなたが提案したい依頼文章を選択します。"
HUMAN_TEMPLATE = """# 命令
あなたはなぜ以下の依頼文章を選択したのか、理由を箇条書きで説明してください。
指示にのみ従って理由を箇条書きで説明してください。余計な説明や注意喚起は不要です。

# 制約条件
- ライティングのジャンルや金額は理由に含めてはいけません
- 依頼の内容よりも、依頼文章そのものに注目すること
- 依頼文章の文体や文章構造にも注目すること
- 出力例の形式に従って箇条書きで書くこと
- 1つの理由を1つの箇条書きに書くこと
- 理由は5個以上10個以下にすること

# 出力例
- 依頼の内容が視覚的に分かりやすく書かれている
- 丁寧な言葉遣いで、依頼者の誠実さが伝わる
- 受注者が提案しやすいように、初心者歓迎や簡単な仕事といった言葉が使われている
- 事前に受注者からの提案に関して、記入すべき項目が明確に書かれている
- コニュニケーションの取りやすさを意識して書かれている
- AIの利用に関する注意事項が書かれている

# あなたが選択した依頼文章: "
タイトル: {title}
本文:
{irai}
"
"""

def main():
    irai_df = pd.read_csv("input.csv")

    # 保存用の新しいDataFrameを作成
    save_df = pd.DataFrame([], columns=['id', 'output', 'prompt_tokns', 'completion_tokens', 'price'])

    # 合計金額を計算
    total_price = 0

    for _, row in irai_df.iterrows():
        title = row["title"]
        description = row["description"]
        messages=[
            {"role": "system", "content": SYSTEM_TEMPLATE},
            {"role": "user", "content": HUMAN_TEMPLATE.format(title=title, irai=description)}
        ]

        # OpenAIのchat.completions.createを呼び出し、responseに代入
        response = client.chat.completions.create(
            model="gpt-3.5-turbo-1106",
            messages=messages,
            temperature=0.9,
        )
        print(response)

        # responseのchoicesの中からmessageを取り出し、outputに代入
        output = response.choices[0].message.content
        print(output)

        # responseのtoken数を取得
        prompt_tokns = response.usage.prompt_tokens
        completion_tokens = response.usage.completion_tokens

        # priceを計算
        price = (prompt_tokns * 0.001 / 1000) + (completion_tokens * 0.002 / 1000)
        print(price)
        total_price += price

        # save_dfにid, output, prompt_tokns, completion_tokens, priceを追加
        save_df.loc[len(save_df)] = {
            'id': row["id"],
            'output': output,
            'prompt_tokns': prompt_tokns,
            'completion_tokens': completion_tokens,
            'price': price
        }
    
        # 処理件数と合計金額を出力
        print(f"処理件数: {len(save_df)}, 合計金額: {total_price}")
    
    # save_dfをcsvファイルとして保存
    save_df.to_csv("output.csv", index=False)

if __name__ == "__main__":
    main()

各データで5個以上10個以下の「受注されやすい理由」が生成されました。
これで、500個以上の「受注されやすい理由」が集まりました。

生成例
- ライティング初心者にも親しめるような、丁寧な言葉遣いで依頼が書かれている
- 記事の構成や参考例が提供されており、初心者でも取り組みやすい環境が整えられている
- 詳細な注意点やマニュアルによる説明が用意されており、ライターが安心して作業できる配慮がなされている
- 執筆に自信がない人や初心者も歓迎されており、積極的な参加が促されている
- 依頼の詳細が具体的に明記されており、受注者にとって適切な提案がしやすい状況が整えられている

受注されやすい理由を分析

大量の「受注されやすい理由」が集まったので、ここから「どんなことを書けば受注されやすいか」を分析していきましょう!

...と、単語であれば出現頻度を計算することで「どんな単語がよく使われてるか」を分析することができるのですが、今回集めた「受注されやすい理由」ではそう簡単にはいきません。
なぜなら、単語は同じ単語を集めれば良いですが、文は字面ではなく意味が同じものを集める必要があるからです。

ここで、同じ意味の文を集めるためにOpenAIのAPIにあるAda(Embeddingモデル)を使ったクラスタリング2を行います。
(クラスタリングの他にも、OpenAIではEmbeddingを利用した活用法3で分類や検索などにも使えるので、ぜひご活用ください。)

クラスタリングとは

私「クラスタリングって何ですか?」

ChatGPT「クラスタリングとは、たくさんのデータの中から似ているもの同士をグループにまとめる方法です。例えば、お店の顧客データがあったとき、年齢や趣味が似ている人たちを一つのグループにすることで、どんな人がどの商品を好むかなどの傾向を見つけられます。このように、クラスタリングは様々なデータを整理し、理解しやすくするのに役立ちます。」

後述のTSNEのグラフを見ていただくとイメージしやすいかもしれません。
同じ色の点が集まったものをクラスタといい、たくさんの点を各色のクラスタに分けることをクラスタリングと言います。

また、ただクラスタリングしただけでは、各クラスタに属する「受注されやすい理由」に共通していることが分からないので、GPT-3.5を使って各クラスタの要約文を生成することにしました。

実行コード
import pandas as pd
import openai
import json
import numpy as np
from sklearn.cluster import KMeans

# OpenAIのクライアントの準備
client = OpenAI()

SYSTEM_TEMPLATE ="あなたは文のリストから共通している部分を見つけ出す優秀なアナリストです。あなたは、文のリストの全てに共通していることをまとめた要約文を作成することです。"
HUMAN_TEMPLATE ="""# 命令
以下の文のリストの全てに共通していることをまとめた要約文を作成してください。異なる文のリストを与えられたときに、異なる要約文となるように、固有で独自のものになるよう注意してください。

# 制約条件
- 要約文は5文字以上30文字以下でなければならない
- 要約文は文のリストの全てに共通していることを表していなければならない
- 要約文は簡潔で分かりやすい言葉を使うこと
- 異なる文のリストを与えられたときに、異なる要約文となるように、固有で独自の要約文でなければならない

# 文のリスト
{texts}
"""

def get_embedding(text, model='text-embedding-ada-002'):
    response = client.embeddings.create(
        input=text,
        model=model,
    )
    prompt_tokns = response.usage.prompt_tokens
    price = prompt_tokns * 0.0001 / 1000
    return response.data[0].embedding, price

def get_summary(text_list):
    functions = [
        {
            "name": "make_summary",
            "description": "文のリストから共通していることをまとめた要約文を作成する",
            "parameters": {
                "type": "object",
                "properties": {
                    "summary": {
                        "type": "string",
                        "description": "文のリストから共通していることをまとめた要約文"
                    }
                },
                "required": ["summary"]
            }
        }
    ]
    texts = "\n".join(text_list)
    messages = [
        {"role": "system", "content": SYSTEM_TEMPLATE},
        {"role": "user", "content": HUMAN_TEMPLATE.format(texts=texts)},
    ]

    response = client.chat.completions.create(
        model="gpt-3.5-turbo-1106",
        messages=messages,
        functions=functions,
        function_call={"name": "make_summary"},
        temperature=0.9,
    )
    print(response)

    # responseのtoken数を取得
    prompt_tokns = response.usage.prompt_tokens
    completion_tokens = response.usage.completion_tokens

    # priceを計算
    price = (prompt_tokns * 0.001 / 1000) + (completion_tokens * 0.002 / 1000)

    response_item = response.choices[0].message
    try:
        function_args = json.loads(response_item.function_call.arguments)
        summary_text = function_args["summary"].strip()
        return summary_text, price
    except:
        return "", price

def main():
    irai_df = pd.read_csv("input.csv")

    description_list = [row["output"] for _, row in irai_df.iterrows()]

    output_list = []
    for description in description_list:
        lines = description.split("\n")
        output_list.extend(lines)

    text_df = pd.DataFrame([], columns=['text', 'embedding', 'price'])
    total_price = 0
    embedding_list = []

    for output in output_list:
        embedding, price = get_embedding(output)
        text_df.loc[len(text_df)] = {'text': output, 'embedding': embedding, 'price': price}
        total_price += price
        embedding_list.append(embedding)

        # 処理件数と合計金額を出力
        print(f"処理件数: {len(text_df)}, 合計金額: {total_price}")
    
    matrix = np.vstack(text_df['embedding'].values)
    n_clusters = 20

    kmeans = KMeans(n_clusters = n_clusters, init='k-means++', random_state=0)
    kmeans.fit(matrix)
    text_df['label'] = kmeans.labels_

    # 後で利用するので embedding と label を保存
    text_df.to_csv("embedding-label.csv", index=False)
    text_list_by_cluster = [text_df[text_df['label'] == i]['text'].tolist() for i in range(n_clusters)]

    cluster_df = pd.DataFrame([], columns=['text', 'count', 'label', 'price'])
    total_price = 0

    for label, text_list in enumerate(text_list_by_cluster):
        summary_text, price = get_summary(text_list)
        cluster_df.loc[len(cluster_df)] = {
            "text": summary_text,
            "count": len(text_list),
            "label": label,
            "price": price
        }
        print(label, summary_text, len(text_list))
        total_price += price
        print(f"処理件数: {len(cluster_df)}, 合計金額: {total_price}")

    # cluster_dfを保存
    cluster_df.to_csv("cluster.csv", index=False)

if __name__ == "__main__":
    main()

20個のクラスタに分類して要約文を生成した結果、以下の結果が得られました。

クラスタリング結果

要約文
ライターに対して具体的で明確な要求と条件が記載されており、スムーズな作業とコミュニケーションが期待されている 47
依頼文章が明確に記載されており、具体的な情報が提示されている 37
具体的な作業の流れや納品方法が明確に記載されており、受注者にとって理解しやすい 35
依頼者の誠実さが伝わる丁寧な言葉遣いで書かれている 34
応募時に要求される情報や条件が明確に記載されており、受注者がスムーズに応募できる 34
依頼内容が具体的で明確に記述されており、作業の範囲や要件が把握しやすい 32
納品方法や納期などが明確に記載されている 32
ビジネス記事の具体的な仕事内容 28
依頼文章が具体的な条件や要件を明確に示している 27
コミュニケーションの取りやすさが重視され、円滑なやり取りが期待される 27
依頼者の求めるスキルや経験が具体的に記載されている 25
依頼者の要望や期待が具体的に示されている 25
報酬に関する情報が明確に記載されており、受注者にとって安心感がある 23
依頼者と受注者の関係が明確に述べられており、信頼関係を築きやすい 21
初心者を歓迎し、興味を引く要素が記載されている 18
記事執筆に関する詳細な情報が提供され、必須条件と歓迎条件が明確に分かれて示されている 17
依頼文章が簡潔であり、明確な構造になっており、情報がわかりやすく整理されている 15
報酬に関する明確な情報が提供されており、ライターの収益についての期待が透明に伝えられている 11
長期的なパートナーシップや協力を希望し、信頼関係を築く意向がある 9
納期や納品方法に関する具体的な情報が明記されており、作業計画やスケジュール管理がしやすい 8

受注されやすい依頼文章には「具体的な内容」や「明確な作業の流れ」が書かれていることが分かりました。

クラスタリング結果の可視化

上記のテーブル情報でも「受注されやすい理由」は分かるのですが、1文ずつ要約文を見ていく必要があります。
また、何も知らない人に「これが分析結果だ!」と見せるには、どれが何を示しているのか説明しないと分からないですよね。
そこで、もっと直感的にするためにワードクラウドで視覚的に理解しやすい形にします。

ワードクラウド

wordcloud0.png

実行コード

wordcloudのデフォルトだと文字化けするので、SourceHanSerifK-Light.otfをダウンロードする必要があります。4

import pandas as pd
from wordcloud import WordCloud as wc
import matplotlib.pyplot as plt

# データをPandasのDataFrameに変換する
df = pd.read_csv("cluster.csv")

# ワードクラウドを作成する
wordcloud = wc(width=800, height=400, background_color='white', font_path='./SourceHanSerifK-Light.otf').generate(' '.join([i for i in df['text']]))

# ワードクラウドをファイルに保存する
wordcloud.to_file('wordcloud.png')

本来、ワードクラウドは名前の通り、単語に対して行うものを今回は無理やり文を表示しているので崩れている部分もありますが、「どんなことを依頼文章に書けば受注されやすいか」が以下のようなことが見えてきました。

  • コミュニケーションを大事にすること
  • 丁寧な言葉遣いで誠実さが伝わること
  • 要求、条件、作業など具体的で明確な情報が記載されていること
  • 受注者が応募しやすい、安心感が持てる文章であること

ただ、ワードクラウドだけでは似ている部分も多く、どんなジャンルがあるのか詳しくクラスタリングの結果が知りたくなってきました。
そこで、要素数の多い上位10個のクラスタをグラフにして見てみましょう。

TSNE

TSNEとは、高次元のベクトル空間を低次元のベクトル空間で表す次元削減のアルゴリズムで、2次元や3次元といったグラフで可視化できる次元数に落とすことによく使われます。
本来は1536次元あるEmbeddingされたベクトルを2次元の空間に変換した結果が以下の通りです。

tsne-10.png

実行コード
from sklearn.manifold import TSNE
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
import japanize_matplotlib

TOP_N = 5

label_df = pd.read_csv("cluster.csv")

label_df = label_df.sort_values('count', ascending=False)
top_labels = []
label_dict = {}
label_color_dict = {}
# 上位20個までの色を用意
total_color_list = [
    "purple", "green", "red", "blue", "yellow", "orange", "pink", "brown", "gray", "black", "gold", "cyan", "lime",
    "silver", "maroon", "olive", "teal", "navy", "fuchsia", "crimson"]
color_list = []
idx = 0
for _, row in label_df.head(TOP_N).iterrows():
    print(row['text'], row['count'])
    top_labels.append(int(row['label']))
    label_dict[int(row['label'])] = row['text']
    label_color_dict[total_color_list[idx]] = int(row['label'])
    color_list.append(total_color_list[idx])
    idx += 1

df = pd.read_csv("embedding-label.csv")

# top_labelsのクラスターのみを抽出
df = df[df['label'].isin(top_labels)]
# dfからembeddingのみを抽出
df = df[['embedding', 'label']]
# dfからembeddingのリストを作成
embeddings = df['embedding'].tolist()
# embeddingsをstrからfloatのリストに変換
embeddings = [eval(embedding) for embedding in embeddings]
# dfのレコード数を確認
matrix = np.vstack(embeddings)

tsne = TSNE(n_components=2, perplexity=15, random_state=0, init="random", learning_rate=200)
vis_dims2 = tsne.fit_transform(matrix)

x = [x for x, y in vis_dims2]
y = [y for x, y in vis_dims2]

# labelのリストを作成
labels = df['label'].tolist()
# labelをstrからintに変換
labels = [int(label) for label in labels]

for category, color in enumerate(color_list):
    indices = [i for i, label in enumerate(labels) if label == label_color_dict[color]]
    xs = np.array(x)[indices]
    ys = np.array(y)[indices]
    plt.scatter(xs, ys, color=color, alpha=0.3)

    avg_x = xs.mean()
    avg_y = ys.mean()

    plt.scatter(avg_x, avg_y, marker="x", color=color, s=100, label=label_dict[label_color_dict[color]])

plt.legend(loc='upper center', bbox_to_anchor=(.5, -.15))

# 画像を保存
plt.savefig("output3_embedding-20/tsne.png", bbox_inches='tight')

中央に集まっている部分はまとめて「具体的で明確な分かりやすい」内容のようです。
中央から離れている青、黒、黄のクラスタを見てみると

  • : 誠実で丁寧な言葉遣い
  • : コミュニケーションの取りやすさ
  • : 受注者の応募のしやすさ

などの「具体的で明確な分かりやすい」とは異なる理由があるようですね。

結果

「ランサーズの受注されやすい依頼文章には 『具体的で明確な依頼』 『誠実で丁寧な言葉遣い』 『コミュニケーションの取りやすさ』 『応募のしやすさ』 が必要」

正直、今回の結果は新しい発見もなく、依頼文章をいくつか見れば分かる程度のことでした。
ただ、今回の「受注されやすい理由」を人の目で分析すると、人の感性に左右されてしまったり、ドメイン知識に依存してしまったり、ついうっかり見逃してしまうようなことがあったかもしれません。
属人化せず、ドメイン依存せず、事実に裏付けされた正確な結果を得られるのは、データを使った分析の良いところですね。

最後に

ということで、ランサーズのデータをOpenAIのAPIでテキストマイニングしてみた話にお付き合いいただき、ありがとうございました。
どんなデータを収集するのか決めるところから、可視化で直感的に結果が分かるようにするところまで、簡単にテキストマイニングの流れを試してみました。

これで、今回の分析で得られた結果を反映しさえすれば、依頼に見積もり書がじゃんじゃん届きますね。

...と言いたいところですが、今回の結果はあくまで「受注されやすい依頼文章」の必要条件であり、十分条件かどうかは検証していません。
簡単にいうと「受注されにくい依頼文章も同じ要素を持っているかもしれない」ということです。
また、本記事はOpenAIのAPIを使って簡単にテキストマイニングができないか試したものなので、プロンプトやLLMの精度に依存し、分析の正確さは保証できません。

余裕があれば以下の2つについても取り組みたいですね。

  • 「受注されやすい文章」と「受注されにくい文章」を比較して、今回の項目に有意な差があるか検証する
  • 人手で評価データセットを用意し、OpenAIのAPIによる判定がどれくらい信用できるか精度を測る

おまけ

最後に知りたい人もいるかもしれないので、お金周りの話を少しだけ。

(要件定義・データ収集・プログラミングなどの初めにかかる時間と費用を除く)OpenAIのAPIを叩くのにかかった時間と費用は、おおよそ以下のようになりました。

時間(分) 費用(円)
データから「受注されやすい理由」を抽出 10 25
「受注されやすい理由」をクラスタリング 4 0.3
「受注されやすい理由」をまとめた要約文を生成 1 4.5
合計 15 29.8

データが約100件なので、1件あたりの費用は約0.3円です。

ワードクラウドと比較すると、費用はかかりますが、単語以外のことも分析できます。
人の目による分析と比較すると、精度は劣りますが、安く早く試せます。

どの方法もメリットとデメリットがあるので、どれが良いということはできませんが、
データを簡単に調べたい初期段階にワードクラウドを使って、
方向性が決まったらOpenAIのAPIを使った大規模なデータ分析を行い、
最後は人手で信頼度の高い確実な検証をすると良いかもしれませんね。

  1. https://www.lancers.jp/help/guide/client/project/1

  2. https://cookbook.openai.com/examples/clustering

  3. https://platform.openai.com/docs/guides/embeddings/use-cases

  4. https://zenn.dev/yagiyuki/articles/e05bc37d6c3bc283c5f1

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?