LoginSignup
2
4

More than 1 year has passed since last update.

Python学習記録_5日目.機械学習のための数学・Pandas入門

Last updated at Posted at 2022-05-02

元記事

Python学習記録_プログラミングガチ初心者がKaggle参加を目指す日記
5日目です。
今日のメインは文系の人にとって多分一番のハードルになるであろう数学についての学習ですね。
1時間やそこらで習得できるものではないと思いますが、Kaggleで手を動かせるようになるのが目的なので
なるべく沼にはまらないようにやっていきたいと思います。

機械学習のための数学 60m …

総和
\sum_{i=1}^{n} x_{i} \\

シグマは和の記号でiをキーとしてNまでの値を合計します。なので、上の式はi=1からNまでのの総和を表します。
式にすると以下のようになります。

\sum_{i=1}^{n} x_{i} = 1 + 2 + 3 + \cdots + n = \frac{n(n+1)}{2} \\

これは2日目にやったfor文を使って表すことができて

a = 0
for i in range(1, 11):
    a = a + i
print(a)

になる。
この総和はnumpyに関数として存在していて

import numpy as  np
x = np.arange(1, 11)
y = np.sum(x)
print(y)

で再現可能。
np.sum(データ名)でリストの総和を出力するという関数らしい。

総乗
\prod_{i=1}^{n} x_{i}\\

パイ(prod)は、$x_{1}$から$x_{n}$までの値の掛け算を表します。
次は$x_{1}$から$x_{n}$までの総乗になります。

\prod_{i = 1}^{n}x_{i}=x_{1}\times x_{2} \times x_{3} \times \dots \times x_{n} \\

で、これもnumpyに関数?モジュール?として実装されてて

import numpy as  np
x = np.arange(1,11)
y = np.prod(x)
print(y)

で再現可能。
総乗を出すときはnp.prod(データ名)になる。

指数関数

$a$を1以外の正の定数とする関数 $f(x)=a^x$ を、$a$を底とする指数関数と呼ぶ。
具体的には
2×2=2^2
2×2×2=2^3
2×2×2×2=2^4
で、底は全て2、上から順に指数が2,3,4となる。
指数関数はaが1より大きければ指数が大きくなるにつれて指数関数の値も大きくなり、
aが1より小さければ指数が大きくなるにつれて指数関数の値は小さくなる。
e(ネイピア数)のx乗を記号で書くと

\exp(x)=e^x \\

となる。
numpyを使うと

import numpy as  np
y = np.exp(x)

でnp.exp()で呼び出し可能。
ネイピア数、少し調べてみましたが微分積分にすげえ便利な数字くらいの理解しか得られませんでした。
eの指数関数は微分しても変化しないとか…
後ほど指数関数の微分というテキストで勉強するらしいですがカリキュラム入ってない上にテキストも見つかりませんでした…
とりあえず2.7くらいの数字なんだなと思っておきます。

対数関数

対数関数はlogを使って表すもので

\log_a x

と書く。
「$a$を何乗すると$x$になるか」
を表すもので、

\log_2 8=3\\
\log_3 27=3\\
\log_4 16=2\\

といった感じ。
これを指数関数の時と同じノリで呼ぶときは

f(x)=\log_a x

を$a$を底、$x$を真数とする対数関数という。
$a$が2、$x$が8の指数関数は3、みたいな使い方ですね。
そしてここにもネイピア数がでてきて
ログナチュラル $\ln$ なるものがあってこれは$e$を底とする対数関数で

\ln_x=\log_e x

と定義される。らしい。
そろそろおなか一杯になってきたけどまだ半分も来てないんだよなコレ…

極限

lim(リミット)は極限を表します。
次のように底で示された変数変化によって関数の値がどこに収束、或いは発散するかについて表します。
例えばxがaに限りなく近いた時、f(x)の値がbに収束するとき、次のように表現します。

\lim_{x \to a} f(x)=b
微分

微分とは、ある関数における区間の変化の割合について、区間を限りなく0に近に近づけることで瞬間として取り出し、その関数における瞬間の変化量を求めることになります。
微分と聞くと、「接線の傾き」を連想する場合も多いかもしれませんが、これはある関数の瞬間の変化量を求める関数が接線の関数となるためです。
関数y=f(x)を微分すると次のように表せます。

f'(x)=
\lim_{\Delta x \to 0} 
\frac{f(x+\Delta x)-f(x)}{\Delta x}

Δx→0のΔ(デルタ)は変化した量を表します。
f′(x)のことを y=f(x)の導関数と呼び、xに何かの値を入れて得られる値を微分係数といいます。
微分を行うにあたり重要なのは、関数 y=f(x)の変数がx1つであることです。次の節の偏微分では、変数が複数ある場合の微分について見ていきます。

気が狂いそうだ…
数学なんて何に使うんだよ笑って言って高校の授業全部寝てた自分を殴り飛ばしたい…

偏微分

偏微分とは「多変数関数」のことで、変数を2つ以上持つ関数に対して、いずれか1つの変数のみに関して微分をすることです。
つまり、特定の変数で微分することです。次に示すのはz = f(x, y)の2変数の場合の偏微分になります。

z=f(x,y)\\
f_x(x,y)=
\lim_{\Delta x \to 0} 
\frac{f(x+\Delta x,y)-f(x,y)}{\Delta x}\\
f_y(x,y)=
\lim_{\Delta y \to 0} 
\frac{f(x,y+\Delta y)-f(x,y)}{\Delta y}

偏微分の基本公式

kを定数としてf(x,y)、g(x,y)が偏微分可能であるとき、次の式が成り立ちます。
image.png

はい、ここで心が折れました。
必要になったらまた数学の勉強はします…

線形代数
行列
逆行列

評価指標(分類) 25m …

はじめに

この章では機械学習の結果を評価する方法に関して説明します。
一部数式が出てきたりしますが、初めから全てを理解しようとせず、徐々に理解できるようになれば問題ありません。
このテキストではScikit-learnを用いて評価の方法を学びますので、実際に手を動かしながら進めてみてください。

分類での評価方法

分類において使う主な評価指標は以下の通りです。
・混同行列(Confusion matrix)
・正解率(Accuracy)
・適合率(Precision)
・再現率(Recall)
・F値(F-measure)
一つずつ見ていきましょう。

気を取り直して進めます。
混同行列は業務で扱ったことがあるのでここはサクサク進められそうです。

混同行列とは

混同行列は二値分類(正事例と負事例の予測)の結果をまとめた表です。
分類結果を表形式にまとめることでどのラベルを正しく分類でき、どのラベルを誤って分類したかを調べることが出来ます。
真の値と予測した値の組み合わせには、それぞれ名称があり以下の図ように呼ばれます。
image.png

混同行列に登場する用語として、以下4つがあります。
True Positive(TP) ・・・真の値が正事例のものに対して、正事例と予測したもの (真陽性)
False Positive(FP) ・・・真の値が負事例のものに対して、正事例と予測したもの(偽陽性)
False Negative(FN) ・・・真の値が正事例のものに対して、負事例と予測したもの(偽陰性)
True Negative(TN) ・・・真の値が負事例のものに対して、負事例と予測したもの(真陰性)
このTP、FP、FN、TNの値を可視化したものが混同行列です。

コロナのPCR検査なんかで一時期騒がれていた偽陽性・偽陰性がありますね。
予測結果のYesNoと実際の結果YesNoの2×2で4パターンになります。
ということでscikit-learnを用いてこれを作っていきます。

from sklearn.metrics import confusion_matrix

y_test = [0,0,0,0,0,1,1,1,1,1] # こっちが実測値 
y_pred = [0,1,0,0,0,0,0,1,1,1] # こっちが予測値

cmatrix = confusion_matrix(y_test,y_pred) 
print(cmatrix)
 # [[4 1]
 #  [2 3]]

構文としてはconfusion_matrix(実測リスト、モデルの予測リスト)になっていて
1番と3~5番が真陰性、8~10番が真陽性、2番が偽陽性、6~7番が偽陽性ですね。
そして混同行列で良く指標として使われるのが

正解率(Accuracy)…どれくらい当てられたか。
→上の例でいくと(4+3)/(4+2+1+3)で0.7

適合率(Precision)…陽性と予測したもののうちどれくらいが本当に陽性か
→上の例で行くと(3)/(1+3)で0.75

再現率(Recall)…本当は陽性の人のうちどれくらい陽性だと予測できているか
→上の例で行くと(3)/(2+3)で0.6

F値(F-measure)…適合率と再現率のバランスをとった指標(調和平均と言うらしい)
→上の例で行くと(20.750.6)/(0.75+0.6)で0.67くらい

の4つ
正解率はそもそもデータに偏りがあるとやたら高く出たりやたら低くなったりするので
参考にするべきはF値だったりする。
どれくらいデータに偏りがあるのかを表す特異度という指標もあったりして
それを使ってAOC曲線を作る、みたいなことをやった記憶があります。

これらの指標をまとめて出してくれる関数が

from sklearn.metrics import classification_report

y_test = [0,0,0,0,0,1,1,1,1,1] # こっちが実測値 
y_pred = [0,1,0,0,0,0,0,1,1,1] # こっちが予測値

print(classification_report(y_test, y_pred)) 
 #               precision    recall  f1-score   support
 #
 #           0       0.67      0.80      0.73         5
 #           1       0.75      0.60      0.67         5
 #
 #    accuracy                           0.70        10
 #   macro avg       0.71      0.70      0.70        10
 #weighted avg       0.71      0.70      0.70        10

こちらのclassification_report(実測リスト,モデルの予測リスト)になっていると。
バカみたいに手動で計算してた自分とはお別れできそうです。

Pandas入門 35m …

Pandasとは

Pandas(パンダス,パンダズ)とは、データ解析を容易にする機能を提供するPythonのデータ解析ライブラリです。
Pandasの特徴には、データフレーム(DataFrame)などの独自のデータ構造が提供されており、様々な処理が可能です。
特に、表形式のデータをSQLまたはRのように操作することが可能で、かつ高速で処理出来ます。
Pandasでは、以下のようなことが出来ます。
・CSVやExcel、RDBなどにデータを入出力できる
・データの前処理(NaN / Not a Number、欠損値)
・データの結合や部分的な取り出しやピボッド(pivot)処理
・データの集約及びグループ演算
・データに対しての統計処理

イメージとしてはAccessっぽい機能ですかね
SQL書いてデータ抽出してpandasで前処理・加工してscikit-learnで確認して~~みたいな流れになりそう

Pandasを使うメリット

Pandasを使うメリットは主に2つあります。

  1. 多種の型のデータを一つのデータフレームで扱えること
    NumPyの配列(np.array)はすべての要素が同じ型でなければなりません。
    よって、csvファイルの読み書きなどでは、NumPyは不便だったりします。
    その点、Pandasのデータフレームは異なる型のデータを入れることが出来ます。
    Pandasのデータフレームに格納することで、データの前処理が容易にできます。

2.データ加工や解析の関数が多いこと
別章で示した欠損値の削除・補間の他にも、これから紹介する様々な便利な関数がPandasには備わっています。

データ型(pandasの基本データ型)

PandasはNumPyをベースとして構築されているため、NumPyのndarrayとの相性が良いです。
2つのデータ型でデータを保持します。

1.シリーズ(Series)
2.データフレーム(DataFrame)

シリーズ
import pandas as pd

s1=pd.Series([1,2,3,5])
print(s1)

 # 0    1
 # 1    2
 # 2    3
 # 3    5
 # dtype: int64

こんな形でインデックスと入力した値しかない2列のデータがシリーズ。

データフレーム

データフレームは2次元のラベル付きのデータ構造で、Pandasでは最も多く使われるデータ型です。
データフレームのイメージとして、スプレッドシートやSQLのテーブルをイメージするとわかりやすいです。
DataFrameは複数の列を持ち、DataFrameから1列を抽出した場合Seriesになります。
また、シリーズと同様に様々な型のデータを保持することが出来ます。

import pandas as pd
df = pd.DataFrame({
    '名前' :['田中', '山田', '高橋'],
    '役割' : ['営業部長', '広報部', '技術責任者'],
    '身長' : [178, 173, 169]
    })
df.columns = ["Name", "Position", "height"]
print(df)
print(df.dtypes)
print(df.columns)
 # 出力結果
  Name Position  height
0   田中     営業部長     178
1   山田      広報部     173
2   高橋    技術責任者     169
Name        object
Position    object
height       int64
dtype: object
Index(['Name', 'Position', 'height'], dtype='object')

先ほどのシリーズを複数まとめたものがデータフレーム。
こっちはカラム名なんかも指定できるみたいです。

head(),tail()
import pandas as pd
import numpy as np
df = pd.DataFrame(np.random.randn(20,2))
df.head() 
df.tail()

df.head().append(df.tail()) 
df.head(3).append(df.tail(3)) 

データの中身というかサンプルを見るために使いそうなのがこのheadとtal
指定しないとheadは先頭から、tailは末尾から5行のデータを持ってくる。
先頭と末尾からそれぞれ持ってくる場合にはappedでつなげばOK。

locとiloc

Pandasのlocとilocは値を抽出するためのメソッドです。
locとilocでは位置の指定方法など以下のような違いがあります。

loc : 行名もしくは列名を指定することで特定の値を抽出
※行名や列名をラベルと置き換えて頂いても問題ありません。

iloc : 行、列を番号(数字が0のインデックス)で指定することで特定の値を抽出

locとilocでは、それぞれリストとスライス表記を用いて範囲を指定することが出来ます。

スライス表記、初出のワードだった(気がする)ので調べました。
:を使って開始位置と終了位置を指定できるもので
[スタート位置:ストップ位置]で指定。
ちなみに[a:b]という指定をした場合、
a以上b未満になるので注意。(以下ではないのでbは含まれない)

loc
import pandas as pd
df = pd.DataFrame([[10, 20], [25, 50]], index=["1行", "2行"], columns=["1列", "2列"])
print(df.loc["1行", :])
1列    10
2列    20
Name: 1行, dtype: int64

locを使う時にはインデックス名とカラム名を引数に指定。
↑の例だと「1行」のインデックス名があるもの全部持ってくるので1行目にある1列目と2列目の値が出力される。
カラム名も縦に並んでるのが少し気持ち悪いような…
インデックス名だけ、カラム名だけで指定することができて
その際には使わない方の引数には:を入力する必要あり。

import pandas as pd
df = pd.DataFrame([[10, 20], [25, 50]], index=["1行", "2行"], columns=["1列", "2列"])
print(df.loc[:,"1列"])
1行    10
2行    25
Name: 1列, dtype: int64

こんな感じで列を指定するとインデックスと値が縦に並んで出てくる。
こっちはあまり違和感ないな…

import pandas as pd
df = pd.DataFrame([[10, 20], [25, 50]], index=["1行", "2行"], columns=["1列", "2列"])
print(df.loc[:,["1列","2列"]])
    1列  2列
1行  10  20
2行  25  50

[]で囲って,でつなげることで複数の要素を指定することも可能。
カラム全部持ってくると元のデータと同じものが出力される。
これはインデックス指定するところで1行と2行を指定しても同じでした。

iloc
import pandas as pd
df = pd.DataFrame([[10, 20], [25, 50]], index=["1行", "2行"], columns=["1列", "2列"])
print(df.iloc[1])
1列    25
2列    50
Name: 2行, dtype: int64

locが行名・列名で指定したのに対しでilocは行番号・列番号で指定する。
ちなみにilocのiはindexのiらしいです。
ilocは特定の行と特定のカラムのデータフレームを取り出すときにも使えて

import pandas as pd
df = pd.DataFrame([[10, 20, 30, 40, ], 
                   [25, 50, 5, 8]], 
                  columns=["A", "B", "C", "D"])
print(df.iloc[0, [0,2,3]])
A    10
C    30
D    40
Name: 0, dtype: int64

こんな使い方も可能…
って書いてあったんですがここで疑問が2つ
①これlocでも同じことができるのでは?
②index番号、loc使ってた時には指定してたのにこっちはなんでしてないんだ?
ということで

import pandas as pd
df = pd.DataFrame([[10, 20, 30, 40], 
                   [25, 50, 5, 8]], 
                  index=["1行目","2行目",],
                  columns=["A", "B", "C", "D"]
                  )
print(df.loc["1行目", ["A","C","D"]])
A    10
C    30
D    40
Name: 1行目, dtype: int64

できました。
実際にデータ扱う時は行番号振ってることってあんまりないのでそういうデータ扱う時はilocの方が便利だよ!
ってことなのかな

条件による行の抽出(query)
import pandas as pd
df = pd.DataFrame([[10, 20,30, 30], [25, 50,65, 80]], index=["1行", "2行"], columns=["A", "B", "C", "D"])
print(df.query('A>=5 and C<50'))
     A   B   C   D
1行  10  20  30  30

.query("条件式")が構文。
文字列を条件設定に使う際には別途''が必要だったり否定条件の場合はnotを使ったり、
複数条件で抽出する時には&で繋いだりと色々細かいルールがある模様…

ファイルの入出力

Pnadasには、ファイルを入出力する機能として、大きく4つの機能を提供しています。

  1. テキスト形式のデータファイルからデータの読み込み
  2. バイナリ形式のデータファイルからデータの読み込み
  3. データベースからのデータの読み込み
  4. Web上からのデータの読み込み

例えば、Pandasでcsvファイルを読み込む場合は、「read_csv」を使います。
データの出力には「to_csv」や「to_excel」などが利用可能です。
csv以外にも、「read_excel」、「read_json」「read_sql」もあり、それらの出力メソッドもあります。

import pandas as pd
data = pd.read_csv("https://aiacademy.jp/dataset/sample_data.csv",
                   encoding="cp932",
                   )
print(data)
      A    B    C    D
0    a0   b0   c0   d0
1    a1   b1   c1   d1
2    a2   b2   c2   d2
3    a3   b3   c3   d3
4    a4   b4   c4   d4
5    a5   b5   c5   d5
6    a6   b6   c6   d6
7    a7   b7   c7   d7
8    a8   b8   c8   d8
9    a9   b9   c9   d9
10  a10  b10  c10  d10
11  a11  b11  c11  d11

こんな形でパスとエンコードを指定することでファイルの読み込みが可能。
出力するときにはto_csv('パス/ファイル名.csv', encoding='utf_8_sig')
で出力する。

Pandas ソート

インデックス (行名・列名)を使う方法と値に基づいてソートする方法があります。
.sort_index()を使うことで、インデックス(カラム名、行名)に基づいてソートを行うことができます。
そのまま使うと、昇順(小さい順)でのソートとなりますが、引数に、ascending=Falseを記述することで降順(大きい順)のソートができます。

import pandas as pd
import numpy as np
df = pd.DataFrame(np.random.randn(20,2))
df.sort_values(by=1, ascending=False)
  0	    1
2	-0.794424	1.938759
3	-0.558690	1.335652
17	0.825481	0.939843
8	1.459711	0.808500
11	-0.421675	0.711095
18	1.940802	0.611913
16	-0.539924	0.537226
0	0.234610	0.286351
1	1.154939	0.222977
4	0.822565	-0.225503
12	1.510765	-0.256003
7	-1.114590	-0.405610
13	-0.582743	-0.454341
5	1.077073	-0.536093
14	-0.468179	-0.628602
19	-0.115815	-0.769607
15	0.422899	-1.260585
9	0.753841	-1.303275
10	-0.837800	-1.370909
6	-0.173246	-1.421738

値でソートする時にはsort_valuesでOK。
SQLでいうとORDER BYですね

欠損値の処理

Pandasには欠損値(NaN)の扱うメソッドは「dropna」、「fillna」、「isnull」、「notnull」があります。
dropnaは指定の軸方向にデータ列を見て、欠損値(NaN)の有無に関して指定の条件を満たす場合に、そのデータ列を削除します。
fillnaは欠損値を指定の値もしくは、指定の方法で埋めることができます。
isnullはデータの要素ごとに、NaNはTrue、それ以外をFalseとして扱い、元のデータと同じサイズのオブジェクトを返します。
notnullは、isnullとは逆の真偽値を返します。

import numpy as np
import pandas as pd
df = pd.DataFrame({"int": [1, np.nan, np.nan, 32],
                   "str": ["python", "ai", np.nan, np.nan],
                   "flt": [5.5, 4.2, -1.2, np.nan]})
print(df)
print(df.fillna(0))
print(df.fillna({"int": 0}))
print(df.fillna({"int": 0,"str":"ai"}))
    int     str  flt
0   1.0  python  5.5
1   NaN      ai  4.2
2   NaN     NaN -1.2
3  32.0     NaN  NaN

    int     str  flt
0   1.0  python  5.5
1   0.0      ai  4.2
2   0.0       0 -1.2
3  32.0       0  0.0

    int     str  flt
0   1.0  python  5.5
1   0.0      ai  4.2
2   0.0     NaN -1.2
3  32.0     NaN  NaN

    int     str  flt
0   1.0  python  5.5
1   0.0      ai  4.2
2   0.0      ai -1.2
3  32.0      ai  NaN

fillnaは便利そうですね
nullを全部○○で埋める、このカラムのnullを○○で埋める、カラムAのnullを○○、カラムBのnullを△△で埋めると
色々な指定の仕方ができるので使うことが多そうな気配…

演習問題
  1. シリーズのデータ型を自由に作ってください。
  2. データフレームのデータ型を自由に作ってください。
  3. あるSNSサービスのユーザー10人のフォロー数やフォロワー数のcsvファイルがあります。
    このファイルのヘッダーがユーザーID,フォロー,フォロワー,いいねが与えられております。
    これらそれぞれuser_id,follow,follower,likeに変換したoutput.csvファイルを出力してください。
    データは下記URLにありますのでダウンロードしてください。

4.d1 = {"data1": ["a","b","c","d","c","a"], "data2": range(6)}を使って、df1という名前のDataFrameを作ってください。
5. 4で作ったデータフレームをcsvファイル(.csv)とエクセルファイル(.xlsx)に書き出してください。
6. 5で作成されたエクセルファイル(.xlsx)とcsvファイル(.csv)をそれぞれ読み込んでください。
7. 5で作成したエクセルファイルに新しい列(カラム)を追加してください。
8. 7に対してappendを使い、行を追加してください。

 #1
import pandas as pd 
a=pd.Series([1,2,3,4,5])
print(a)

 #2
import pandas as pd
a=pd.DataFrame({"name":["佐藤","田中","鈴木"],"sex":["M","F","M"],"age":[25,32,29]})
print(a)

 #3
import pandas as pd
data=pd.read_csv("/content/sns_data.csv")
data.columns=["user_id","follow","follower","like"]
data.to_csv("/content/output.csv",encoding='utf_8_sig')

 #4
import pandas as pd
d1 = {"data1": ["a","b","c","d","c","a"], "data2": range(6)}
df1=pd.DataFrame(d1)
print(df1)

 #5,6,7,8
import pandas as pd
d1={"data1": ["a","b","c","d","c","a"], "data2": range(6)}
df1=pd.DataFrame(d1)
df1.to_csv("/content/output2.csv",encoding='utf_8_sig')
df1.to_excel("/content/output2.xlsx",encoding='utf8')
dt1=pd.read_csv("/content/output2.csv")
dt2=pd.read_excel("/content/output2.xlsx")
dt2["data3"]=["f","e","d","c","b","a"]
dt2=dt2.iloc[:,1:4]
print(dt2)
dt3=pd.Series(["g",6,"z"],index=dt2.columns)
print(dt3)
dt2=dt2.append(dt3,ignore_index = True)
print(dt2)
 #1
0    1
1    2
2    3
3    4
4    5
dtype: int64

 #2
  name sex  age
0   佐藤   M   25
1   田中   F   32
2   鈴木   M   29

 #5,6,7,8
  data1  data2 data3
0     a      0     f
1     b      1     e
2     c      2     d
3     d      3     c
4     c      4     b
5     a      5     a

data1    g
data2    6
data3    z
dtype: object

  data1  data2 data3
0     a      0     f
1     b      1     e
2     c      2     d
3     d      3     c
4     c      4     b
5     a      5     a
6     g      6     z

5日目の感想

例題の8がクソでした。自分がスムーズに解けない問いは全てクソ。
まあ冗談は置いといて、
データフレームに行を追加する時に追加元データは追加先データのindexを参照しないといけないこととか
appendの第2引数にignore_indexで指定しないといけないこととか習ってない規則がいくつか出てきてかなり時間がかかってしまいました。
もし今までに習ってて忘れてるだけだったら木の下に埋めてもらっても構わないよ!!
とはいえエラーの原因はググって解決する、が基本だとは思うのでそういう意味では良い演習だったような気もします。

数学とPandasというかなり時間かけてやらないといけない2つを1日に詰め込んだせいで予定より遅れてしまいましたが
雑な理解のまま進めるよりは良いと思う(ことで自己正当化していく)ので、引き続きやっていきます。

少しずつ、本当に少しずつですが土台ができてきたような気もするので
明日以降も頑張ります。

2
4
1

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
4