LoginSignup
217
167

More than 1 year has passed since last update.

あなたの文章に合った「いらすとや」画像をレコメンド♪(アルゴリズム解説編)

Last updated at Posted at 2019-02-24

はじめに

本記事はあなたの文章に合った「いらすとや」画像をレコメンド♪シリーズの第2回、アルゴリズム解説編です。
文章を与えると、それに近い意味を持った「いらすとや」画像を探してレコメンドしてくれるアプリのアルゴリズムについて解説します。

機能概要は第1回、あなたの文章に合った「いらすとや」画像をレコメンド♪(機能概要編)をご参照ください。

アルゴリズムの概要

本アプリの基本的なアイディアは次のとおりです。

  1. 与えられた文や画像の説明文を、それぞれ文の分散表現(つまりはベクトル)に変換する。
  2. 与えられた文と画像の説明文の意味の近さを、それぞれの文の分散表現を使って計算する(意味の近さ = 2つのベクトルのなす角の小ささ = コサイン類似度の大きさとする)。
  3. コサイン類似度が大きい説明文を持つ画像トップN個を選ぶことで、与えられた文と意味が近い画像を発見できる。

模式図にすると、次のようになります。
アルゴリズム概要.png
文章から文の分散表現を作るステップは次とおりです。
文の分散表現作成方法.png

詳細版

文の分散表現の計算方法.png

以下、これらの実装方法を1つ1つ順を追って解説いたします。

前提知識・スキル・マインド

入門的なことから解説すると大変なので、次の前提知識・スキル・マインドを持っている人を想定読者とさせてください。

必要環境・データ

  • OS, HW
    1. Macで動作確認済みです。おそらくLinuxでも動作します。
    2. メモリは1〜2GBは必要でしょう(学習済み分散表現モデルのサイズが約1GBあります)。
  • コマンドラインツール
    1. mecab (辞書はIPA辞書を使用してください)
  • pythonライブラリ
    1. jupyter notebook
    2. mecab-python3
    3. pymagnitude
    4. numpy
    5. tqdm
  • データ
    1. 学習済み分散表現モデル:
    2. いらすとや」さんの画像メタデータ:
      • ダウンロード: https://drive.google.com/file/d/1DZjgYCda82IYAUhbmsJin5A8y9Y-lNyw/view?usp=sharing
        ※これは「いらすとや」さん(みふねたかしさん)から、AIによる検索技術の向上やその教育のためにとご配慮いただき、今回特別に公開許可いただけた94件のデータです。本データの著作権は「みふねたかし」さんに帰属します。
        ※このデータは次の単語を説明文に含む画像を抽出したものです。
        • 抽出用キーワード: AI, つづく, 拍手, 祈る, 笑い, お礼
      • メタデータの形式は次のとおりです。
画像メタデータの形式
[{'title': '画像1のタイトル', 'desc': '画像1の説明文', 'page': '画像1のページのURL', 'imgs': ['画像1の1つ目のURL', ...]},
 {'title': '画像2のタイトル', 'desc': '画像2の説明文', 'page': '画像2のページのURL', 'imgs': ['画像2の1つ目のURL', ...]},
 ...]

今回、上記の2つのデータを作る方法の説明は省略します。

ライセンス(願い)

ソースコードのライセンスはAGPLv3もしくは、下記のライセンスとさせてください。

このソースコードのライセンスに込めた気持ちは、一言でいえば「これを参考に検索アプリを作ったら、学習データをオープンにすること」です。

日本語による自然言語処理の研究において、学習データがあまりないという問題があります。
そのため、日本語の自然言語処理の発展に繋がるよう、ソースコードは次のライセンスのもと公開させてください。
もし何か問題がありましたら、いいお知恵をください。

  • ライセンス(願い)
    1. 本記事やソースコードをもとにして不特定多数が利用する検索サービスや検索アプリを作った場合、次のデータ提供要望を受けつける仕組みを用意し、要望があれば、無条件で次のデータを提供すること。
      1. ユーザからリクエスト情報(検索のためにユーザから与えられた文など)
      2. レスポンス情報(1のリクエスト情報に対応する検索結果情報(リクエスト情報と画像結果となったページURLのペアなど))
      3. ユーザからのフィードバック情報(教師ラベルとなる、2の検索結果のうちユーザが実際クリックしたURLの情報や、検索結果のうち変なものを指摘するフィードバック情報など)
    2. 教師ラベルとなるユーザからのフィードバック情報を得る仕組みを用意すること。
    3. 利用者からこのような公開が行われることへの同意を得ること。
    4. 派生したソースコードを公開するときに、同じライセンスのもと公開すること。

法律の専門家ではないため、全然ダメなライセンス説明でしょうが、、、
この自然言語処理の発展を祈る気持ちをご理解いただき、適切な行動をしていただけたらという願いです。
もっといい方法がございましたら、ご教授お願いいたします。

アルゴリズム実装

それでは順番にアルゴリズムを作っていきましょう。
以下の解説のコードはJupyter Notebookで順番に実行すれば動作するようになっております。

実は、今回解説するアルゴリズムはわざと素朴なものにしています。
素朴にした理由は、今回のアルゴリズムを改良・拡張するというお題を通して自然言語処理について学ぶ初学者向けの教材にするためです。いわゆるベースラインです。お題の詳細は次回解説いたします。

※以下の説明では、画像メタデータとして、全件を用いた場合と、本解説記事用の94件のデータを用いた場合で違う部分があります。注釈「全件の場合」「94件の場合」を目印にしてどちらの場合か見分けてください。

本記事再現用ソースコードのダウンロード

本記事の内容をJupyter NotebookやGoogle Colaboratoryで再現するためのソースコードを下記リポジトリで公開しています。Google Colaboratory用の方がツールやデータの準備が不要な分だけ再現が簡単です。

fastTextの学習済みモデルを読み込む

単語の分散表現を計算するためのfastTextの学習済みモデルを読み込みます。
ファイルパスは https://drive.google.com/file/d/16NYoJrQAX_Y72fgwBrK_ZHxgumbK9ZX3/view?usp=sharing からダウンロードして解凍したファイル群のパスに適宜変えてください。
解凍後ファイルサイズが約1GBあるため読み込みに少々時間がかかります。

ファイル群のパス指定方法への補足

ダウンロードして解凍すると次のようなファイルになります。
jawiki.ipadic.fasttext.ws5-neg5-epoch5.magnitude

このファイル群が fasttext_model というディレクトリに格納されている場合、次のソースコードのようにパスを指定すればモデルが読み込まれます。

In[1]
from pymagnitude import *
fasttext_model = Magnitude("jawiki.ipadic.fasttext.ws5-neg5-epoch5.magnitude", normalized=False, ngram_oov=True, case_insensitive=True)

動作確認として、有名なアナロジー問題を計算してみます。
男でいう王子は女でいう何か?というアナロジー問題です。結果は期待通り王女になっています。ちゃんと動いていますね。
(ただし、類似度が1を超えていますので、類似度としてベクトルの間のcos類似度を使っていないようです(未確認)。うーん、この仕様はどうなのでしょう。今回は使わないので深追いはやめておきます)

In[2]
similarities = fasttext_model.most_similar(positive=['王子', '女'], negative=['男'])
similarities
Out[2]
[('王女', 3.1692638),
 ('おうじ', 2.8932414),
 ('王妃', 2.685927),
 ('フョードロヴナ', 2.6620784),
 ('パヴロヴナ', 2.6589048),
 ('マリー・ド・ブルボン', 2.6414475),
 ('ジャンヌ・ド・ブルボン', 2.5927687),
 ('麟作', 2.5649061),
 ('妃', 2.562044),
 ('太子', 2.5114424)]

今回は、文の意味の近さを、文の分散表現のコサイン類似度によって測ります。
文の意味が近ければ、文の分散表現(ベクトル)v1とv2が近くなるという定性的性質を、ベクトルの成す角のcosによって測るということです。
(意味を有限次元のベクトルに圧縮して表現するのであるから、ベクトルが近いからといって必ずしも意味が近くなるとはいえませんが、割り切って受け入れるということです)

In[3]
import numpy as np
def cos_sim(v1, v2):
    v1 = v1 / np.linalg.norm(v1, axis=0, ord=2)
    v2 = v2 / np.linalg.norm(v2, axis=0, ord=2)
    return np.sum(v1 * v2)

正規化処理

本解説ではneologdの正規化処理を少し変えたものを利用します。
neologdの正規化処理: https://github.com/neologd/mecab-ipadic-neologd/wiki/Regexp.ja
他にも色々と正規化の方法はありうるでしょう。

In[4]
# https://github.com/neologd/mecab-ipadic-neologd/wiki/Regexp.ja から引用・一部改変
from __future__ import unicode_literals
import re
import unicodedata

def unicode_normalize(cls, s):
    pt = re.compile('([{}]+)'.format(cls))

    def norm(c):
        return unicodedata.normalize('NFKC', c) if pt.match(c) else c

    s = ''.join(norm(x) for x in re.split(pt, s))
    s = re.sub('-', '-', s)
    return s

def remove_extra_spaces(s):
    s = re.sub('[  ]+', ' ', s)
    blocks = ''.join(('\u4E00-\u9FFF',  # CJK UNIFIED IDEOGRAPHS
                      '\u3040-\u309F',  # HIRAGANA
                      '\u30A0-\u30FF',  # KATAKANA
                      '\u3000-\u303F',  # CJK SYMBOLS AND PUNCTUATION
                      '\uFF00-\uFFEF'   # HALFWIDTH AND FULLWIDTH FORMS
                      ))
    basic_latin = '\u0000-\u007F'

    def remove_space_between(cls1, cls2, s):
        p = re.compile('([{}]) ([{}])'.format(cls1, cls2))
        while p.search(s):
            s = p.sub(r'\1\2', s)
        return s

    s = remove_space_between(blocks, blocks, s)
    s = remove_space_between(blocks, basic_latin, s)
    s = remove_space_between(basic_latin, blocks, s)
    return s

def normalize_neologd(s):
    s = s.strip()
    s = unicode_normalize('0-9A-Za-z。-゚', s)

    def maketrans(f, t):
        return {ord(x): ord(y) for x, y in zip(f, t)}

    s = re.sub('[˗֊‐‑‒–⁃⁻₋−]+', '-', s)  # normalize hyphens
    s = re.sub('[﹣-ー—―─━ー]+', 'ー', s)  # normalize choonpus
    s = re.sub('[~∼∾〜〰~]+', '〜', s)  # normalize tildes (modified by Isao Sonobe)
    s = s.translate(
        maketrans('!"#$%&\'()*+,-./:;<=>?@[¥]^_`{|}~。、・「」',
              '!”#$%&’()*+,-./:;<=>?@[¥]^_`{|}〜。、・「」'))

    s = remove_extra_spaces(s)
    s = unicode_normalize('!”#$%&’()*+,-./:;<>?@[¥]^_`{|}〜', s)  # keep =,・,「,」
    s = re.sub('[’]', '\'', s)
    s = re.sub('[”]', '"', s)
    s = s.upper()
    return s
In[5]
def normalize_text(text):
    return normalize_neologd(text)

形態素解析

MeCabを用いて正規化済み文字列を形態素解析します。辞書はIPA辞書を使用してください。

In[6]
import MeCab
mecab = MeCab.Tagger("-d /usr/local/lib/mecab/dic/ipadic")
In[7]
class Morph(object):
    def __init__(self, surface, pos, base):
        self.surface = surface
        self.pos = pos
        self.base = base
    def __repr__(self):
        return str({
            "surface": self.surface,
            "pos": self.pos,
            "base": self.base
        })

def tokenize(sentence):
    sentence = normalize_text(sentence)
    mecab.parse("")
    lines = mecab.parse(sentence).split("\n")
    tokens = []
    for line in lines:
        elems = line.split("\t")
        if len(elems) < 2:
            continue
        surface = elems[0]
        if len(surface):
            feature = elems[1].split(",")
            base = surface if len(feature) < 7 or feature[6] == "*" else feature[6]
            pos = ",".join(feature[0:4])
            tokens.append(Morph(surface=surface, pos=pos, base=base))
    return tokens

試しに形態素解析してみます。surfaceは形態素、posは品詞、baseは原形です。

In[8]
tokenize("MeCabを用いて正規化済み文字列を形態素解析します!!")
Out[8]
[{'surface': 'MECAB', 'pos': '名詞,一般,*,*', 'base': 'MECAB'},
 {'surface': 'を', 'pos': '助詞,格助詞,一般,*', 'base': 'を'},
 {'surface': '用い', 'pos': '動詞,自立,*,*', 'base': '用いる'},
 {'surface': 'て', 'pos': '助詞,接続助詞,*,*', 'base': 'て'},
 {'surface': '正規', 'pos': '名詞,形容動詞語幹,*,*', 'base': '正規'},
 {'surface': '化', 'pos': '名詞,接尾,サ変接続,*', 'base': '化'},
 {'surface': '済み', 'pos': '名詞,接尾,一般,*', 'base': '済み'},
 {'surface': '文字', 'pos': '名詞,一般,*,*', 'base': '文字'},
 {'surface': '列', 'pos': '名詞,一般,*,*', 'base': '列'},
 {'surface': 'を', 'pos': '助詞,格助詞,一般,*', 'base': 'を'},
 {'surface': '形態素', 'pos': '名詞,一般,*,*', 'base': '形態素'},
 {'surface': '解析', 'pos': '名詞,サ変接続,*,*', 'base': '解析'},
 {'surface': 'し', 'pos': '動詞,自立,*,*', 'base': 'する'},
 {'surface': 'ます', 'pos': '助動詞,*,*,*', 'base': 'ます'},
 {'surface': '!!', 'pos': '名詞,サ変接続,*,*', 'base': '!!'}]

画像メタデータを読み込む

In[9](94件の場合)
import json

with open('irasuto_items_part.json', 'r') as items_file:
    items = json.load(items_file)

前処理: 不要な形態素を除外する

重要な意味を持たなかったりノイズになったりする形態素(ストップワード)を除外します。
今回用いた除外方法は次の2つです。

  1. 重要でない品詞は除外する。
  2. 多くの文章に現れている形態素を除外する。

全説明文に現れる形態素と品詞を出現頻度の高い順にみて決めました。
今回、結果的に重要ではないとした品詞は以下 stop_pos のもの、重要でない形態素は「イラスト」「する」「(」「)」の4つと「!」や「?」で構成される形態素にしました。

今回はこのような人力で採用・不採用を決めてゼロイチでお重み付けする素朴な方法を用いましたが、他にもSCDVのように出現頻度情報(古典的にはTF-IDF)で重み付けしたり、アテンションの類で形態素の重要度を重み付けする方法を使えばもっと精度が上がるかもしれません。もちろん、試してみないと実感に合うかどうか分かりませんし、トレードオフ(用途の向き不向き、非機能面の改悪)もあるでしょう。

In[10]
stop_pos = {
    "助詞,格助詞,一般,*",
    "助詞,格助詞,引用,*",
    "助詞,格助詞,連語,*",
    "助詞,係助詞,*,*",
    "助詞,終助詞,*,*",
    "助詞,接続助詞,*,*",
    "助詞,特殊,*,*",
    "助詞,副詞化,*,*",
    "助詞,副助詞,*,*",
    "助詞,副助詞/並立助詞/終助詞,*,*",
    "助詞,並立助詞,*,*",
    "助詞,連体化,*,*",
    "助動詞,*,*,*",
    "記号,句点,*,*",
    "記号,読点,*,*",
    "記号,空白,*,*",
    "記号,一般,*,*",
    "記号,アルファベット,*,*",
    "記号,一般,*,*",
    "記号,括弧開,*,*",
    "記号,括弧閉,*,*",
    "動詞,接尾,*,*",
    "動詞,非自立,*,*",
    "名詞,非自立,一般,*",
    "名詞,非自立,形容動詞語幹,*",
    "名詞,非自立,助動詞語幹,*",
    "名詞,非自立,副詞可能,*",
    "名詞,接尾,助動詞語幹,*",
    "名詞,接尾,人名,*",
    "接頭詞,名詞接続,*,*"
}

vocab = {}
for item in items:
    desc = item["desc"]
    title = item["title"]
    tokens = tokenize(desc)
    for token in tokens:
        key = token.base
        pos = token.pos
        is_stop = pos in stop_pos
        v = vocab.get(key, { "count": 0, "pos": pos , "stop": is_stop})
        v["count"] += 1
        vocab[key] = v

vocab_list = []
for k in vocab:
    v = vocab[k]
    if not v["stop"]:
        vocab_list.append((v["count"], k, v["pos"], v["stop"]))

stop_posに含まれない品詞の形態素を出現頻度の高い順に一覧化します。
タプルの情報は、左から出現頻度、形態素の原形、品詞、stop_posに含まれるか否か(Falseのみ)です。

In[11]
vocab_list = sorted(vocab_list, reverse=True)
vocab_list[:10]
Out[11](94件の場合)
[(90, 'イラスト', '名詞,一般,*,*', False),
 (72, '(', '名詞,サ変接続,*,*', False),
 (71, ')', '名詞,サ変接続,*,*', False),
 (55, 'する', '動詞,自立,*,*', False),
 (30, 'AI', '名詞,固有名詞,組織,*', False),
 (26, '人工', '名詞,一般,*,*', False),
 (25, '知能', '名詞,一般,*,*', False),
 (19, 'キャラクター', '名詞,一般,*,*', False),
 (18, '女性', '名詞,一般,*,*', False),
 (17, '男性', '名詞,一般,*,*', False)]
Out[11](全件の場合)
[(20619, 'イラスト', '名詞,一般,*,*', False),
 (8609, 'する', '動詞,自立,*,*', False),
 (5629, '(', '名詞,サ変接続,*,*', False),
 (5295, ')', '名詞,サ変接続,*,*', False),
 (2434, '女性', '名詞,一般,*,*', False),
 (2216, '男性', '名詞,一般,*,*', False),
 (1769, '使う', '動詞,自立,*,*', False),
 (1194, '着る', '動詞,自立,*,*', False),
 (1167, 'なる', '動詞,自立,*,*', False),
 (1096, '人', '名詞,一般,*,*', False)]

94件の場合: 今回、画像データの50%以上に現れているトップ4の形態素は意味を持たないと考えて除外します。

In[12](94件の場合)
stop_word = [w[1] for w in vocab_list[:4]]
stop_word
Out[12](94件の場合)
['イラスト', '(', ')', 'する']

全件の場合: 今回、全件(約20,000件)の画像データの25%以上に現れているトップ4の形態素は意味を持たないと考えて除外します。

In[12](全件の場合)
stop_word = [w[1] for w in vocab_list[:4]]
stop_word
Out[12](全件の場合)
['イラスト', 'する', '(', ')']

mecabでは記号が"名詞,サ変接続,*,*"になることがあるため、!や?で構成される形態素も除外します。もっとパターンを増やした方がいいでしょう。

In[13]
import re
stop_word_regex = [ re.compile("^[!?]+$")]

与えられた文を、文の分散表現に変換する関数 get_sentence_vector を定義します。
今回採用した文の分散表現の計算方法は次の通りです。

  1. 正規化・形態素解析・前処理を行う。
  2. 1で求めた形態素列の各形態素に対応する分散表現をfastTextを用いて計算する。
  3. 2で求めた単語の分散表現の単純和を文の分散表現とする。

今回は形態素をそのまま入力にして学習したfastTextを使用するため、形態素そのままを入力にして分散表現を計算しています。もし、word2vec等で形態素の原形を用いて学習した場合は、形態素の原形を入力に分散表現を計算するといいでしょう。

今回は(意図して)極めて素朴な文の分散表現計算方法(単語の分散表現の単純和)を採用しましたが、他にも(あまり精度は変わらないかもしれませんが)doc2vecや、より高度なニューラル言語モデル(Universal Sentence EncoderやQuick-Thoughts Vectorなど)や、工夫いるでしょうがBERTなどを用いて深い文脈情報を持った文の分散表現を作ってもいいでしょう。

In[14]
def get_sentence_vector(sentence):
    tokens = tokenize(sentence)
    vecs = []
    for token in tokens:
        if is_stop(token):
            continue
        surface = token.surface
        v = fasttext_model.query(surface)
#         v = v / np.linalg.norm(v, axis=0, ord=2)
        vecs.append(v)

    sent_vec = None
    for vec in vecs:
        if sent_vec is None:
            sent_vec = vec
        else:
            sent_vec = sent_vec + vec
    return sent_vec

def is_stop(token):
    return token.pos in stop_pos or token.base in stop_word or any([r for r in stop_word_regex if r.match(token.base) is not None])

試しに文ベクトルを計算してみます。

In[15]
get_sentence_vector("与えられた文から文の分散表現を計算します。")
Out[15]
array([-1.5151137e-01, -3.1349602e-01,  6.7971635e-01,  1.0311966e+00,
        2.8439978e-01,  6.2196982e-01,  9.4056803e-01,  1.9055775e+00,
        9.5409608e-01, -8.8091004e-01,  1.7558415e-01, -1.5682095e+00,
       -6.8752211e-01,  9.9586457e-02,  1.0331454e+00,  9.7016275e-01,
        ...

画像メタデータに説明文の分散表現を追加します。

In[16]
from tqdm import tqdm
for item in tqdm(items):
    desc = item["desc"]
    desc_vec = get_sentence_vector(desc)
    item["vec"] = desc_vec

最後のステップです。画像を検索する関数を定義します。
いままで作った関数を使えば、次の処理からなる検索アルゴリズム(最初の図も参照)を簡単に実装できますね。

  1. 与えられた文から文の分散表現を計算する。
  2. その分散表現と、説明文の分散表現の間のコサイン類似度を計算する。
  3. コサイン類似度の高い順に画像の関連情報を表示する。

※なお「いらすとや」さんの広告収入モデルに悪影響を与えないよう、必ず「いらすとや」さんのページへのリンクを張り、画像のダウンロードは「いらすとや」さんのページから行うようにしましょう。その他、「いらすとや」さんの利用規約に違反しないよう十分ご注意ください。

In[17]
from IPython.display import display, HTML, clear_output
from html import escape

def search_irasuto(sentence, top_n=3):
    sentence_vector = get_sentence_vector(sentence)
    sims = []
    if sentence_vector is None:
        print("検索できない文章です。もう少し文章を長くしてみてください。")
    else:
        for item in items:
            v = item["vec"]
            if v is None:
                sims.append(-1.0)
            else:
                sim = cos_sim(sentence_vector, v)
                sims.append(sim)

    count = 0
    for index in np.argsort(sims)[::-1]:
        if count >= top_n:
            break
        item = items[index]
        desc = escape(item["desc"])
        imgs = item["imgs"]
        if len(imgs) == 0:
            continue
        img = imgs[0]
        page = item["page"]
        sim = sims[index]
        display(HTML("<div><a href='" + page + "' target='_blank' rel='noopener noreferrer'><img src='" + img + "' width='100'>" + str(sim) + ": " + desc + "</a><div>"))
        count += 1

アプリの動作確認

さあ、これでアルゴリズムは完成しました。早速、試してみましょう。
※今回の94件の解説用データは「AI, つづく, 拍手, 祈る, 笑い, お礼」という単語を説明文に含む画像に限定したもののため、これらの単語に関する検索のみが行えます。それでも十分アルゴリズムについて理解できるでしょう。

In[18]
search_irasuto(sentence="暴走したAI", top_n=5)

暴走したAI.png

免責事項

著者は本シリーズ記事を掲載するにあたって、その内容、機能等について細心の注意を払っておりますが、内容が正確であるかどうか、安全なものであるか等について保証をするものではなく、何らの責任を負うものではありません。
本記事内容のご利用により、万一、ご利用者様に何らかの不都合や損害が発生したとしても、著者や著者の所属組織(新日鉄住金ソリューションズ株式会社(NSSOL)。なお、2019年4月から商号が日鉄ソリューションズ株式会社(NSSOL)に変わります)は何らの責任を負うものではありません。

謝辞

いらすとや」という素晴らしいサービスを公開され、
また、今回の解説記事と画像メタデータの公開についてご承諾くださいました「みふねたかし」様に感謝いたします。

In[19]
search_irasuto(sentence="いらすとやさんに惜しみない拍手を", top_n=1)

いらすとやさんに惜しみない拍手を.png

つづく

シリーズの次回は言語処理100本ノックのその先編です。

と思ったのですが、、、その前に、文章をまるっと与えると挿絵をレコメンドしてくれるアプリを作る応用編を書きました。

今回、解説したアルゴリズムはわざと素朴なものにしています。
素朴にした理由は、今回のアルゴリズムを改良・拡張するというお題を通して自然言語処理について学ぶ初学者向けの教材にするためです。いわゆるベースラインです。

本記事が自然言語処理技術者の育成や技術の発展に繋がりましたら光栄です。

In[20]
search_irasuto(sentence="つづく", top_n=1)

つづく.png

217
167
3

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
217
167