実践編Practice
ソースコードを測って読み解く
複数の Python ソースを解析し、行数・循環的複雑度・関数数といったコードメトリクスのデータセットを自分の手で作ります。そのうえで「規模が大きいほど複雑なのか」を相関で調べ、KMeans で似た性質のコードを群に分けます。プログラム解析・前処理・機械学習(教師なし)を、1 本のパイプラインとしてつなぐ総合演習です。
この教材は日本語のみで提供していますThis learning material is available in Japanese only.
課題設定The task
コードを「数字」にして眺めてみる
プログラムを読むとき、私たちは「この関数は長い」「この分岐は入り組んでいる」と感覚的に感じています。その感覚を再現できる数値に置き換えるのがコードメトリクスです。行数(LOC)、循環的複雑度、関数やクラスの数などを機械的に測れば、多数のファイルを同じものさしで並べて比べられます。
この演習では、次の流れを一続きにたどります。
- 収集:解析対象の Python ソースをそろえる(標準ライブラリのモジュール群を使い、自己完結させます)。
- 計測:各ファイルを
astとradonで解析し、メトリクスの表にする。 - 前処理:pandas で表を整え、外れ値を確認し、標準化する。
- 分析:規模と複雑さの相関を調べ、KMeans で似たファイルを群に分ける。
- 考察:どんな群に分かれたか、この見方の限界は何かを言葉にする。
最終的に得られるのは、140 ほどのモジュールを 6 つの指標で並べたデータセットと、それらを 4 群に分けたクラスタリング結果です。教師あり学習と違って正解ラベルはありません。データそのものの構造を見つけにいく、教師なしの分析を体験します。
この演習のパイプライン
使う技術Techniques used
3 つのモジュールを 1 本につなぐ
この演習は、これまで単体で学んできた次の 3 つの技術を組み合わせます。まだ学んでいないものがあれば、先にそのモジュールへ寄り道してから戻ってくると理解がスムーズです。
プログラム解析Program analysis
- ソースを構文木として読む。
astとradonで行数・複雑度・関数数を測る土台。 - プログラム解析のモジュールへGo to Program Analysis
データの前処理Data preprocessing
- 表の整形と標準化。pandas での集計、外れ値の確認、
StandardScalerによる尺度そろえ。 - 前処理のモジュールへGo to Data Preprocessing
機械学習(教師なし)Machine learning
- ラベルなしで構造を見つける。
KMeansによるクラスタリングとPCAによる可視化。 - 機械学習のモジュールへGo to Machine Learning
ノートブックで実行Run it
このページのコードを、そのまま動かす
practice-code-metrics.ipynb
下のステップで読むコードと同じ内容のノートブックです。Colab で開けば、ライブラリのインストールも含めて上から順に実行できます。手元の Jupyter で動かしたい場合はダウンロードしてください。
先頭のセルで !pip install radon scikit-learn pandas matplotlib japanize-matplotlib を実行します(japanize-matplotlib はグラフの日本語ラベルを正しく表示するためのものです)。解析対象は Colab に最初から入っている標準ライブラリのソースなので、追加のデータ取得は不要です。表示される件数や数値は Python のバージョンによって多少変わります。
解析するソースを集める
Collect the sourcesこの段でやることWhat we do
最初に、測る対象の Python ファイルをそろえます。外部からダウンロードしてもよいのですが、この演習では標準ライブラリのモジュールを使います。どの環境にも最初から入っていて、ネットワークがなくても動き、しかも書き手の異なる多様なコードが数多く含まれているからです。
標準ライブラリの置き場所は sysconfig.get_paths()["stdlib"] で分かります。そこにある単一ファイルのモジュール(*.py)を集め、名前が _ で始まる内部用のものは除きます。
import sysconfig, glob, os
# 標準ライブラリの置き場所を調べ、単一ファイルのモジュールを集める
stdlib_dir = sysconfig.get_paths()["stdlib"]
paths = sorted(glob.glob(os.path.join(stdlib_dir, "*.py")))
# 名前が "_" で始まる内部用モジュールは除く
paths = [p for p in paths if not os.path.basename(p).startswith("_")]
print("解析対象:", stdlib_dir)
print("候補ファイル数:", len(paths))
print("例:", [os.path.basename(p) for p in paths[:6]])
解析対象: /usr/lib/python3.11
候補ファイル数: 152
例: ['abc.py', 'aifc.py', 'antigravity.py', 'argparse.py', 'ast.py', 'asynchat.py']この Step のねらい
解析対象を「手に入りやすく、量があり、多様」なものにすると、後の分析が安定します。標準ライブラリはその条件をすべて満たす、身近で自己完結したデータ源です。
ast と radon で計測する
Measure with ast and radon仕組みHow it works
1 つのファイルから、次のメトリクスを取り出します。
- 行数系:
radon.raw.analyzeが返すloc(総行数)、sloc(コメントや空行を除いた実行行数)、comments(コメント行数)など。 - 関数数・クラス数:
ast.parseで構文木を作り、ast.walkで全ノードをたどってFunctionDefとClassDefを数えます。 - 循環的複雑度:
radon.complexity.cc_visitが、関数・メソッドごとの複雑度を返します。ここでは最大値max_ccと平均mean_ccを使います。
循環的複雑度(cyclomatic complexity)は、コードの中に分岐がいくつあるかを測る古典的な指標です。実用的には次のように数えます。
グラフ理論では制御フローグラフに対して M = E - N + 2P(E:辺, N:節点, P:連結成分数)と定義され、上の数え方はその実用的な等価表現です。
解析中に構文エラーや文字コードの問題が起きるファイルもあるため、例外は握りつぶして None を返し、後で取り除けるようにしておきます。
import ast
from radon.raw import analyze
from radon.complexity import cc_visit
def measure(path):
name = os.path.splitext(os.path.basename(path))[0]
src = open(path, encoding="utf-8", errors="ignore").read()
try:
raw = analyze(src) # 行数系(loc, sloc, comments ...)
tree = ast.parse(src) # 構文木
blocks = cc_visit(src) # 関数・メソッドごとの循環的複雑度
except (SyntaxError, UnicodeError):
return None
funcs = [n for n in ast.walk(tree)
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
classes = [n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)]
ccs = [b.complexity for b in blocks]
return {
"module": name,
"loc": raw.loc, "sloc": raw.sloc, "comments": raw.comments,
"functions": len(funcs), "classes": len(classes),
"max_cc": max(ccs) if ccs else 0,
"mean_cc": round(sum(ccs) / len(ccs), 2) if ccs else 0.0,
}
print(measure(os.path.join(stdlib_dir, "csv.py")))
{'module': 'csv', 'loc': 444, 'sloc': 289, 'comments': 57,
'functions': 17, 'classes': 8, 'max_cc': 24, 'mean_cc': 4.5}cc_visit は関数ごとの複雑度を持っているので、「どの関数がいちばん入り組んでいるか」も調べられます。たとえば tokenize モジュールで複雑度の高い順に並べてみます。
src = open(os.path.join(stdlib_dir, "tokenize.py"), encoding="utf-8").read()
for b in sorted(cc_visit(src), key=lambda b: -b.complexity)[:3]:
print(f"{b.name:<16} cc={b.complexity}")
_tokenize cc=53
untokenize cc=12
compat cc=11この Step のまとめ
ast は構造(何がいくつあるか)を、radon は量(行数)と複雑さ(分岐の多さ)を教えてくれます。両者を組み合わせると、1 ファイルを数個の数字で表せます。
表に整え、外れ値を見て、標準化する
Build, clean, and scaleこの段でやることWhat we do
すべてのファイルに measure を適用し、None(解析できなかったもの)を除いて pandas の表にします。describe() で分布のあらましを眺めると、値の幅がとても広いことが分かります。
ここで外れ値にも目を向けます。標準ライブラリには、中身が別モジュールへの数行の転送しかない「薄いラッパ」も含まれます。そうしたファイルは sloc が極端に小さく、規模と複雑さの分析ではノイズになりがちです。ここでは sloc が 40 行未満のものを除きます(除外の基準は目的に応じて決めます)。
import pandas as pd
# すべてのファイルを計測し、失敗した(None の)ものは除く
records = [m for p in paths if (m := measure(p)) is not None]
df = pd.DataFrame(records)
print("解析できたモジュール数:", len(df))
# 薄いラッパなど極端に小さいファイルは外れ値として除く
df = df[df["sloc"] >= 40].reset_index(drop=True)
print("前処理後:", len(df))
features = ["sloc", "comments", "functions", "classes", "max_cc", "mean_cc"]
print(df[features].describe().round(1))
解析できたモジュール数: 152
前処理後: 141
sloc comments functions classes max_cc mean_cc
count 141.0 141.0 141.0 141.0 141.0 141.0
mean 507.4 81.4 45.1 5.0 18.4 4.0
std 493.8 96.3 45.5 6.8 12.0 2.3
min 45.0 0.0 0.0 0.0 0.0 0.0
25% 170.0 16.0 13.0 1.0 10.0 2.7
50% 375.0 50.0 29.0 3.0 16.0 3.5
75% 622.0 105.0 59.0 6.0 23.0 5.0
max 2267.0 507.0 225.0 48.0 72.0 20.8次に標準化します。sloc は数百から数千、mean_cc は 1 桁と、指標ごとに桁が違います。このまま距離を測ると sloc だけが結果を支配してしまいます。StandardScaler で各列を「平均 0・標準偏差 1」にそろえ、すべての指標を対等に扱えるようにします。
from sklearn.preprocessing import StandardScaler
X = StandardScaler().fit_transform(df[features])
print("標準化後の平均:", X.mean(axis=0).round(2))
print("標準化後の標準偏差:", X.std(axis=0).round(2))
標準化後の平均: [-0. 0. 0. 0. 0. 0.]
標準化後の標準偏差: [1. 1. 1. 1. 1. 1.]この Step のまとめ
生の計測値を「表に整える → 外れ値を確認する → 尺度をそろえる」の 3 手で、分析に使える形にしました。特に標準化は、距離にもとづく KMeans や PCA の前では欠かせません。
規模と複雑さの関係を調べる
Size versus complexityこの段でやることWhat we do
クラスタリングの前に、指標どうしの関係を相関係数で確かめます。「大きいコードほど複雑なのか」という素朴な問いに、数字で答えてみます。相関係数はマイナス 1 から 1 の値をとり、1 に近いほど一方が増えると他方も増える関係が強いことを表します。
corr = df[features].corr()
print("規模(sloc) と 最大複雑度(max_cc):", round(corr.loc["sloc", "max_cc"], 3))
print("規模(sloc) と 関数数(functions):", round(corr.loc["sloc", "functions"], 3))
print("関数数 と クラス数:", round(corr.loc["functions", "classes"], 3))
規模(sloc) と 最大複雑度(max_cc): 0.518
規模(sloc) と 関数数(functions): 0.861
関数数 と クラス数: 0.804読み取れることは 2 つあります。第一に、規模と関数数はとても強く相関します(0.861)。大きいファイルには関数がたくさんあるのは当然で、これは驚きではありません。第二に、規模と最大複雑度の相関はほどほどです(0.518)。つまり「大きい=どこかに必ず複雑な関数がある」とまでは言えず、小さいのに一部だけ極端に複雑なファイルや、大きいのに全体的に素直なファイルが存在することを示唆します。この「規模だけでは説明しきれない部分」こそ、次のクラスタリングで見えてきます。
散布図にすると関係が一目で分かります。
# 日本語ラベルの豆腐(□)を防ぐ:Colab では先頭のセルで !pip install japanize-matplotlib
import matplotlib.pyplot as plt
import japanize_matplotlib # noqa: F401(import するだけで日本語フォントが有効になる)
plt.figure(figsize=(6, 4))
plt.scatter(df["sloc"], df["max_cc"], s=18, alpha=0.6)
plt.xlabel("sloc (実行行数)")
plt.ylabel("max_cc (最大循環的複雑度)")
plt.title("規模 vs 複雑さ")
plt.tight_layout()
plt.show()
# 右上がりの広がった雲が描かれる。左下(小さく素直)に多くの点が集まり、
# 右へ行くほど点はまばらになる。同じ sloc でも max_cc は上下に大きくばらつく。この Step のまとめ
相関は「全体の傾向」を 1 つの数字にまとめます。規模と複雑さはある程度連動しますが、完全ではありません。この「ずれ」を持つファイルたちを、次は群として捉えます。
KMeans で似たコードを群化する
Cluster with KMeans and PCA仕組みHow it works
KMeans は、標準化した 6 次元空間の中で、点(=ファイル)どうしの近さをもとに k 個の群に分ける教師なし手法です。ここでは k=4 にします。正解ラベルはないので、分けたあとで各群の平均を見て、その群がどんな性格のコードなのかを人間が解釈します。
from sklearn.cluster import KMeans
km = KMeans(n_clusters=4, random_state=42, n_init=10)
df["cluster"] = km.fit_predict(X)
print(df["cluster"].value_counts().sort_index())
print(df.groupby("cluster")[features].mean().round(1))
cluster
0 17
1 39
2 49
3 36
Name: count, dtype: int64
sloc comments functions classes max_cc mean_cc
cluster
0 1607.6 273.2 127.1 15.6 34.4 4.2
1 307.9 49.1 18.1 1.3 24.2 6.2
2 178.7 23.1 20.4 2.1 8.7 2.7
3 651.3 105.1 69.4 8.0 17.7 3.1各群のメンバーを少し覗いてみます。
for c in sorted(df["cluster"].unique()):
names = df[df["cluster"] == c]["module"].head(5).tolist()
print(f"cluster {c}:", ", ".join(names))
cluster 0: argparse, dataclasses, datetime, doctest, enum
cluster 1: base64, cgi, cgitb, cmd, compileall
cluster 2: abc, asynchat, bisect, bz2, cProfile
cluster 3: aifc, ast, asyncore, bdb, calendar群の番号自体には意味がありません(実行のたびに入れ替わり得ます)が、各群の平均から性格が読み取れます。
- 大規模で機能豊富な群:実行行数も関数・クラス数も飛び抜けて多い(
argparse・datetimeなど)。中に複雑な関数も抱えます。 - 小さいのに局所的に複雑な群:規模は小さいのに、最大複雑度と平均複雑度が高い。少数の関数に分岐が凝縮したコードです。
- 小さく素直な群:規模が小さく複雑度も低い、見通しのよいモジュール群。数の上ではいちばん多数派です。
- 中規模でクラス構成的な群:ほどほどの規模で、クラスと関数がバランスよく並ぶ。
6 次元のままでは目で見られないので、PCA で 2 次元に落として散布図にします。PCA は情報の損失をできるだけ抑えて次元を圧縮する手法です。
from sklearn.decomposition import PCA
pca = PCA(n_components=2)
pts = pca.fit_transform(X)
plt.figure(figsize=(6, 5))
plt.scatter(pts[:, 0], pts[:, 1], c=df["cluster"], cmap="viridis", s=22, alpha=0.75)
plt.xlabel("PC1"); plt.ylabel("PC2")
plt.title("KMeans クラスタ(PCAで2次元に投影)")
plt.tight_layout()
plt.show()
print("PCAが説明する分散の割合:", pca.explained_variance_ratio_.round(3))
PCAが説明する分散の割合: [0.556 0.254]第 1 主成分と第 2 主成分で、もとの情報の約 81 パーセント(0.556 + 0.254)を保てています。散布図では、素直な群が原点付近に密集し、大規模な群が一方向に大きく離れて分布します。規模の軸(PC1)が最も大きなばらつきを説明していることが読み取れます。
この Step のまとめ
KMeans はラベルなしで「似たもの集め」をします。分けること自体より、各群の平均を読み、名前を付ける解釈の作業が本番です。PCA は、その結果を目で確かめるための窓になります。
結果を読み、限界を知る
Interpret and its limits考察Discussion
この演習で分かったことを整理します。標準ライブラリのモジュールは、規模と複雑さでおおむね次のように分かれました。大多数は「小さく素直」で、その対極に「大規模で機能豊富」なごく少数のモジュールがあります。そして両者の間に、「小さいのに一部だけ複雑」なものと「中規模でクラス構成的」なものが位置します。相関で見た「規模だけでは説明しきれない部分」が、群として姿を現したわけです。
ここで大切なのは、複雑度が高いことは、必ずしも悪いことではないという点です。字句解析やパーサ(tokenize の _tokenize のような関数)は、本質的に多くの場合分けを必要とします。数字は「ここに注意を向ける価値がある」という目印であって、良し悪しの判定そのものではありません。メトリクスは会話のきっかけにするものだと考えるのが健全です。
この分析で言えることWhat holds
- 多数のファイルを同じ尺度で並べ、外れて見えるコードを機械的に見つけられる。
- 規模と複雑さは連動するが、完全ではない。ずれを持つコードが群として存在する。
- 教師なしでも、平均を読むことで各群に意味づけができる。
限界と注意Limits
- メトリクスは表面的な形だけを測り、設計の良さや正しさは測れない。
- 循環的複雑度は分岐の数であって、読みやすさそのものではない。
kの値・特徴量の選び方・外れ値の基準で結果は変わる。唯一の正解はない。- 高複雑度は「悪い」ではなく「注意して見る価値がある」の意味にとどめる。
この Step のまとめ
数値は判断を置き換えるものではなく、判断を助ける道具です。「どこを詳しく読むか」を絞り込むために使い、最後は人間がコードを読んで確かめる。この順番を忘れないことが、メトリクスと上手に付き合うコツです。
発展課題Try more
こう変えたら、どう変わる?
手を動かして確かめると理解が深まります。ノートブックの数字を書き換えて、次を試してみてください。方向性だけ示します。答えは自分の目で確かめましょう。
1. 別のコードを対象にするAnalyze other code
- 解析対象を、
pipで入れた小さな公開パッケージのソース(そのインストール先ディレクトリ)に差し替えてみる。 - ヒント:
パッケージ名.__path__でソースの場所が分かります。標準ライブラリと群の分かれ方が変わるか観察を。
2. 特徴量を足すAdd features
radonのh_visit(Halstead 指標)や、コメント行と実行行の比を特徴量に加える。- ヒント:軸が増えると群の意味づけがどう細かくなるかを見ます。標準化を忘れずに。
3. クラスタ数を変えるChange k
kを 3 や 6 に変えて、群の分かれ方を比べる。- ヒント:
kごとにkm.inertia_を記録してエルボー法で「ほどよいk」を探すのも定番です。
4. 保守性の指標を計算するMaintainability index
radon.metrics.mi_visitで保守性指数(Maintainability Index)を出し、群ごとに比べる。- ヒント:規模・複雑度・コメント量を 1 つにまとめた指標です。群の性格と一致するか確かめましょう。
まとめWrap-up
プログラム解析から機械学習まで、一続きで
この演習では、ソースコードを数値に変え、その数値からコードの構造を読み解きました。通ってきた道を振り返ります。
- 収集:標準ライブラリのモジュールを、自己完結した解析対象としてそろえた。
- 計測:
astとradonで、行数・複雑度・関数数を測った。 - 前処理:pandas で表に整え、外れ値を除き、
StandardScalerで尺度をそろえた。 - 分析:相関で規模と複雑さの関係を確かめ、
KMeansとPCAで似たコードを群化・可視化した。 - 考察:各群に意味づけをし、メトリクスの使いどころと限界を整理した。
使った 3 つのモジュールへ、いつでも戻って復習できます。
使った技術に戻るRevisit the techniques
- プログラム解析Program Analysis:ast と構文木の基礎。
- データの前処理Data Preprocessing:表の整形と標準化。
- 機械学習Machine Learning:KMeans と教師なし学習。
次の演習・段階へNext exercise
- 次の演習:Next: 意味で検索する(TF-IDF vs 埋め込み)Semantic search (TF-IDF vs embeddings)(演習一覧All exercises)
- クラスタ数や特徴量を変え、群の分かれ方の変化を観察する。
- Halstead 指標や保守性指数を足して、測れる側面を広げる。
- 解析対象を実プロジェクトに変え、注意して読むべき箇所を絞り込む。
おつかれさまでした
プログラム解析・前処理・機械学習という別々に学んだ技術が、1 本のパイプラインでつながりました。ノートブックを開いて、k の値や特徴量、外れ値の基準を書き換えてみてください。群の分かれ方がどう動くかを確かめると、それぞれの手法が結果に与える影響が体で分かります。