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

「AIっぽい日本語」を3つの数値で測る。形態素解析なしで動く50行

1
Posted at

自分で書いた文章を読み返して、AIが書いたように見えたことはありませんか。

わたしはあります。しかも、どこがそう見えるのかを説明できませんでした。文単位で読むと問題がない。通して読むと、のっぺりしている。感覚だけがあって、指す場所がない。

指す場所を作りたくて、3つの数値を測ることにしました。形態素解析器は使いません。標準ライブラリだけで動きます。

先に結果

同じ物差しで4本を測った結果です。上の1本は、この記事のために手で書いた「いかにもAIが書きそうな文章」です。

文章 CV 語尾トップ2 平均文長
AI風のサンプル 0.301 1.000 22.5
自分の記事A 0.526 0.597 26.6
自分の記事B 0.471 0.392 23.3
自分の記事C 0.521 0.407 22.2

**平均文長は、ほとんど差がありません。**22.5と22.2です。ここを見ていても何も分かりません。

差が出たのは、ばらつきのほうでした。

指標1・文長の変動係数(CV)

文の長さの標準偏差を、平均で割った値です。

mean = statistics.mean(lens)
sd = statistics.pstdev(lens)
cv = sd / mean

なぜ標準偏差そのものを使わないかというと、平均文長が違う文章を比べられなくなるからです。平均40字の文章で標準偏差12なら自然ですが、平均15字で標準偏差12なら極端に暴れています。割ることで、長さの水準に関係なく比べられます。

人が書いた文章は、長い説明のあとに短い言い切りが来ます。畳みかけたあと、ひと呼吸置く。この上下がCVに出ます。

生成された文章は、1文あたりの情報量がそろいます。結果として長さもそろう。0.4を下回ったら、読み返す合図にしています。上のサンプルは0.301でした。

指標2・語尾トップ2比率

文末の型を数えて、上位2種類が全体に占める割合です。

ENDINGS = [
    ('ました', re.compile(r'ました[。!?]?$')),
    ('ません', re.compile(r'ませ[んぬ][。!?]?$')),
    ('ます',   re.compile(r'ます[。!?]?$')),
    ('でした', re.compile(r'でした[。!?]?$')),
    ('です',   re.compile(r'です[。!?]?$')),
    ('',     re.compile(r'[^ん]だ[。!?]?$')),
    ('である', re.compile(r'である[。!?]?$')),
    ('動詞終止', re.compile(r'[うくぐすつぬぶむるいえきけしせちてにねひへみめりれ][。!?]?$')),
]

判定は上から順に当てます。順番が重要です。「ました」を「ます」より先に置かないと、すべて「ます」に吸われます。「ません」も同様で、「ん」を含む語との衝突を避けるために先に置きます。

AI風サンプルの比率は 1.000 でした。14文がすべて「ます」か「です」です。読んでいて単調に感じる直接の原因が、ここに出ます。

自分の記事は 0.392〜0.597 でした。0.6を超えると、読み返して1〜2文だけ言い切りに変えます。全部を変える必要はありません。リズムは、外れた文が数本あるだけで生まれます。

指標3・体言止め率

上のどれにも当たらなかった文の割合です。名詞で終わる文と、記号や引用で終わる文が入ります。

厳密な体言止めの検出ではありません。**「型に当てはまらなかった文がどれくらいあるか」**を見ているだけです。それでも役に立ちます。0だと、全部が定型で終わっているということなので。

逆に0.25を超えると、今度は体言止めが多すぎて読みにくくなります。0.05〜0.20あたりが、自分の場合は落ち着いていました。

前処理のほうが手間だった

指標の計算より、測る対象を決めるほうが面倒でした。

Markdownで書いているので、そのまま数えるとコードブロックが1文になります。表の行も、見出しも、URLも混ざる。

CODE_BLOCK = re.compile(r'```.*?```', re.S)
INLINE = re.compile(r'`[^`]*`')
URL = re.compile(r'https?://\S+')
MD = re.compile(r'^(#{1,6}\s|[-*+]\s|\d+\.\s|>\s|\|)')

def strip_markup(text):
    text = CODE_BLOCK.sub('', text)
    text = INLINE.sub('', text)
    text = URL.sub('', text)
    lines = [l for l in text.split('\n') if not MD.match(l.strip())]
    return '\n'.join(lines)

見出しと箇条書きを落としているのは、そこが地の文ではないからです。箇条書きは体言止めになりやすいので、残すと体言止め率が跳ね上がります。何を測っているのか分からない数字になります。

文の分割は、句点のうしろで切っています。

SENT_END = re.compile(r'(?<=[。!?])')

後読みで切るので、句点が文の末尾に残ります。語尾判定で [。!?]?$ を書いているのは、そのためです。

4文字未満は捨てています。表の残骸や、記号だけの行が混ざるためです。

全体(50行)

"""日本語文章の均一さを測る。標準ライブラリのみ。"""
import re
import statistics
from collections import Counter

SENT_END = re.compile(r'(?<=[。!?])')
CODE_BLOCK = re.compile(r'```.*?```', re.S)
INLINE = re.compile(r'`[^`]*`')
URL = re.compile(r'https?://\S+')
MD = re.compile(r'^(#{1,6}\s|[-*+]\s|\d+\.\s|>\s|\|)')

ENDINGS = [
    ('ました', re.compile(r'ました[。!?]?$')),
    ('ません', re.compile(r'ませ[んぬ][。!?]?$')),
    ('ます',   re.compile(r'ます[。!?]?$')),
    ('でした', re.compile(r'でした[。!?]?$')),
    ('です',   re.compile(r'です[。!?]?$')),
    ('',     re.compile(r'[^ん]だ[。!?]?$')),
    ('である', re.compile(r'である[。!?]?$')),
    ('動詞終止', re.compile(r'[うくぐすつぬぶむるいえきけしせちてにねひへみめりれ][。!?]?$')),
]


def strip_markup(text):
    text = CODE_BLOCK.sub('', text)
    text = INLINE.sub('', text)
    text = URL.sub('', text)
    lines = [l for l in text.split('\n') if not MD.match(l.strip())]
    return '\n'.join(lines)


def sentences(text):
    out = []
    for line in strip_markup(text).split('\n'):
        line = line.strip()
        if not line:
            continue
        for s in SENT_END.split(line):
            s = s.strip()
            if len(s) >= 4:
                out.append(s)
    return out


def classify(s):
    for name, pat in ENDINGS:
        if pat.search(s):
            return name
    return '体言止め等'


def measure(text):
    ss = sentences(text)
    lens = [len(s) for s in ss]
    if len(lens) < 2:
        return None
    mean = statistics.mean(lens)
    sd = statistics.pstdev(lens)
    c = Counter(classify(s) for s in ss)
    return {
        '文数': len(ss),
        '平均文長': round(mean, 1),
        'CV': round(sd / mean, 3),
        '語尾トップ2比率': round(sum(n for _, n in c.most_common(2)) / len(ss), 3),
        '体言止め率': round(c['体言止め等'] / len(ss), 3),
        '語尾分布': c.most_common(5),
    }


if __name__ == '__main__':
    import sys
    for path in sys.argv[1:]:
        r = measure(open(path, encoding='utf-8').read())
        print(f'=== {path} ===')
        if not r:
            print('  文が足りません')
            continue
        for k, v in r.items():
            print(f'  {k}: {v}')

実行はこれだけです。

$ python3 metrics.py article.md
=== article.md ===
  文数: 91
  平均文長: 22.2
  CV: 0.521
  語尾トップ2比率: 0.407
  体言止め率: 0.099
  語尾分布: [('ます', 21), ('です', 16), ('ました', 15), ('ません', 11), ('でした', 10)]

使うときの前提

**この数値は品質ではありません。**均一さです。

CVが高い文章が読みやすいとは限りません。長短が激しすぎて散らかっている場合もCVは上がります。逆に、手順書のように意図的に文体をそろえた文章は、CVが低くて当然です。

わたしは公開前に一度通して、閾値を外れたときだけ本文を見に行く使い方をしています。**数値で直すのではなく、読み返す場所を決めるために使う。**この距離感を外すと、数値を上げるための文章になります。

閾値は、自分の過去記事を何本か測って決めるのがいちばん早いと思います。わたしの場合はCV 0.4以上、語尾トップ2は0.6未満に落ち着きました。ここは書き手によって変わるはずです。

余談

この記事自体を測ったら、文数85・CV 0.446・語尾トップ2が 0.553 でした。自分で決めた閾値には収まっています。

ただ、コードブロックが本文の半分近くを占めているので、前処理で落ちたあとの文数は多くありません。**サンプルが少ないと数値は暴れます。**30文を下回るときは、参考程度に見ています。

短い記事ほど、この物差しは効きません。皮肉なことに、短い記事のほうが単調になりやすいのですが。


ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。

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