雑記:1つの一様乱数から2つの独立な一様乱数ができる

2024-03-11 この記事で示そうとしたことをまとめた PDF が以下です。
20240309_asympstat.pdf - Google ドライブ



お気付きの点がありましたらご指摘いただけますと幸いです。

[1] の 99 ページに以下のようにありますね。U区間 [0, 1] の値を取る一様乱数です。

It is possible to produce two independent uniform [0, 1] variables U_1 and U_2 from one given [0, 1] variable U. (For instance, construct U_1 and U_2 from the even and odd numbered digits in the decimal expansion of U.)
つまり、1つの一様乱数 U から2つの独立な一様乱数 U_1, U_2 がつくれると。つくり方の一例としては、 U の小数点以下の奇数番目の数をとって詰めて U_1 とし、同様に偶数番目をとって U_2 にするんですね。例えば以下でしょうか。
  •  U = 0.12345678 \cdots だったら、 U_1, U_2 を以下のようにとる。
    •  U_1 = 0.1357 \cdots
    •  U_2 = 0.2468 \cdots
この U_1, U_2 も一様乱数であり、互いに独立というのは直感的にはそうでしょう。元の U が一様乱数である以上、「奇数番目はこの並びになりやすい」などということはないでしょうし、「奇数番目がこの並びなら偶数番目はこの並びになりやすい」ということもないでしょう。しかし、本当にそうなるのか、[1] には何も説明がありませんね。ここは私が証明してみましょう。

いま示したいことは以下ですね。

補題〈 1つの一様乱数から2つの独立な一様乱数ができる 〉
U[0, 1] 上の一様分布にしたがう確率変数とする。いま、U の小数点以下の奇数番目の数をつなげて詰めて U_1 を、同様に偶数番目の数をつなげて詰めて U_2 をつくる。このとき、U_1U_2 も一様分布にしたがう確率変数であり、かつ、互いに独立である。
……まずは U_1, U_2 の分布を知りたいですよね。例えば、\mathrm{P}(U_1 < 0.5) がいくらになるのか考えてみましょう。 U_1 < 0.5 という事象を  U についての事象に翻訳しないとわかりませんね。U がどうあるべきかを知りたいので、 U_1 < 0.5 をどの桁の数字がどうあるべきかでいい直すと「U_1 \,の小数第1位が5未満」ですね。逆に、これだけです。U_1 の他の桁への要請はありません。であれば……U_1 に小数第1位を供給するのは U の小数第1位ですから、U への要請も「U \,の小数第1位が5未満」だけです。そうなると
 \mathrm{P}(U_1 < 0.5) = \mathrm{P}( U_1 \,の小数第1位が5未満) = \mathrm{P}( U \,の小数第1位が5未満) = \mathrm{P}(U < 0.5) = 0.5
一様分布の確率になりますね。というか、同じ論理で以下までいえますね。
 0 < a < 1 である a の小数点以下が1桁のとき、 \mathrm{P}(U_1 < a) =  \mathrm{P}(U < a) = a

では、小数点以下が2桁のときはどうなるでしょうか。\mathrm{P}(U_1 < 0.53) を考えてみましょう。 さっきと同様に、 U_1 < 0.53 をどの桁の数字がどうあるべきかでいい直してみましょう。

 \quad \mathrm{P}(U_1 < 0.53) = \mathrm{P}( U_1 \,の小数第1位が5未満、または、小数第1位が5かつ小数第2位が3未満)
 \quad  \qquad \qquad \qquad = \mathrm{P}( U \,の小数第1位が5未満、または、小数第1位が5かつ小数第3位が3未満)
 \quad  \qquad \qquad \qquad = \mathrm{P}( U \,の小数第1位が5未満) \, + \, \mathrm{P}( U \, の小数第1位が5かつ小数第3位が3未満) \quad \text{★}
 \begin{split} \quad \qquad \qquad \quad \; \, \, &= \mathrm{P}(U < 0.5) \\ & \quad + \mathrm{P}(0.500 \leqq U < 0.503) + \mathrm{P}(0.510 \leqq U < 0.513) \\ & \quad + \mathrm{P}(0.520 \leqq U < 0.523) + \mathrm{P}(0.530 \leqq U < 0.533) \\ & \quad + \mathrm{P}(0.540 \leqq U < 0.543) + \mathrm{P}(0.550 \leqq U < 0.553) \\ & \quad + \mathrm{P}(0.560 \leqq U < 0.563) + \mathrm{P}(0.570 \leqq U < 0.573) \\ & \quad + \mathrm{P}(0.580 \leqq U < 0.583) + \mathrm{P}(0.590 \leqq U < 0.593)  \\ &= 0.5 + 10 \times 0.003  \\ &= 0.53 \end{split}

やはり一様分布の確率になりました! 小数点以下が2桁になると、必然的にどの桁がどうあるべきかへの要請が「A または B」になりますね。そして、この「A または B」を U についての事象に翻訳すると排反事象ですから確率の和となり ( \text{★})、さらにこの第 2 項は U 上の 10 個の区間に分裂しますね。なぜなら、「小数第1位が5かつ小数第3位が3未満」とは「小数第1位が5かつ小数第2位が0かつ小数第3位が3未満、または、小数第1位が5かつ小数第2位が1かつ小数第3位が3未満、または、……」であるからです。

こうなると、小数点以下の桁数が3のときにも拡張できそうですね。つまり、以下です。

 \begin{split} \quad \mathrm{P}(U_1 < 0.534) &= \mathrm{P}(U < 0.5) \\ & \quad + 10 \times \mathrm{P}(0.500 \leqq U < 0.503) \quad \text{★} \\ & \quad + 10^2 \times \mathrm{P}(0.50300 \leqq U < 0.50304)  \quad \text{★★} \\ &= 0.5 + 10 \times 0.003 + 10^2 \times 0.00004  \\ &= 0.534 \end{split}

 \text{★} では小数第2位が本当は10通りあるので係数 10 が出てきて、 \text{★★} では小数第2位も小数第4位も10通りあるので係数 10^2 が出てくるというわけです。区間幅が同じなら確率が同じなので便宜上小数第2位や小数第4位が 0 のときの区間を代表に書いてしまっていますが……。

これはさらに一般化できそうですね。いま、 0 < a < 1 である a の小数第 k 位の数を a_k とします。また便宜上 a_0=0 とします。そうすると、a の小数点以下の桁数が有限の  n ならば以下になります。

 \begin{split} \quad \displaystyle \mathrm{P}(U_1 < a) &=\mathrm{P} \left( U_1 < \sum_{k=1}^n 10^{-k} a_k \right) \\ &= \sum_{k=1}^n 10^{k - 1} \mathrm{P} \left(\sum_{k'=0}^{k-1} 10^{-2k'+1} a_{k'} \leqq U < \sum_{k'=0}^{k} 10^{-2k'+1} a_{k'} \right)  \quad \text{★★★} \\ &= \sum_{k=1}^n 10^{k - 1} 10^{-2k+1} a_k \\ &= \sum_{k=1}^n 10^{-k} a_k \\ &= a \end{split}

さらに、 \text{★★★} にあらわれる U 上の区間たちは本当は互いに共通部分をもちません。であれば、一様測度の可算加法性より、「互いに共通部分がない無数の区間の和の確率」は「各区間の確率の和の極限」に等しいので、a の小数点以下の桁数が無限であっても以下が成り立ちます。よって U_1 は一様乱数です。

 \quad \displaystyle \mathrm{P}(U_1 < a) =\mathrm{P} \left( U_1 < \lim_{n \to \infty} \sum_{k=1}^n 10^{-k} a_k \right) = \lim_{n \to \infty} \sum_{k=1}^n 10^{-k} a_k = a

そして U_2 も一様乱数になりますね。U_1 とは  \text{★★★} の部分がやや異なりますが、一様分布にしたがうことが確認できます。

 \begin{split} \quad \displaystyle \mathrm{P}(U_2 < a) &=\mathrm{P} \left( U_2 < \sum_{k=1}^n 10^{-k} a_k \right) \\ &= \sum_{k=1}^n 10^{k} \mathrm{P} \left(\sum_{k'=0}^{k-1} 10^{-2k'} a_{k'} \leqq U < \sum_{k'=0}^{k} 10^{-2k'} a_{k'} \right)  \quad \text{★★★'} \\ &= \sum_{k=1}^n 10^{k} 10^{-2k} a_k \\ &= \sum_{k=1}^n 10^{-k} a_k \\ &= a \end{split}

U_1U_2 も一様乱数であることは確認できたので、これらが互いに独立であることを示しましょう。つまり、 0 < a, b < 1 であるどんな a, b についても、\mathrm{P}(U_1 < a, U_2 < b) =\mathrm{P}(U_1 < a) \mathrm{P}(U_2 < b) =ab であることを示しましょう。

しかし、どうすればいいのやら。具体的な例から考えてみますか。\mathrm{P}(U_1 < 0.34, \, U_2 < 0.56) を考えると、ab も小数点以下が2桁なので、どちらも U のどの桁がどうあるべきかに「A または B」というタイプの要請をしてきます。なので、U への要請は 4 つに分かれます。それでこの場合は結局、独立な 2 つの一様分布の同時分布になります。

 P(U_1 < 0.34, U_2 < 0.56)
 
 = P(U_1 < 0.3, U_2 < 0.5)
   + P(U_1 < 0.3, 0.5 ≦ U_2 < 0.56)
   + P(0.3 ≦ U_1 < 0.34, U_2 < 0.5)
   + P(0.3 ≦ U_1 < 0.34, 0.5 ≦ U_2 < 0.56)
 
 =   P(U の 小数第1位が3未満 かつ 小数第2位が5未満)
   + P(U の 小数第1が3未満 かつ 小数第2位が5 かつ 小数第4位が6未満)
   + P(U の 小数第1位が3 かつ 小数第2位が5未満 かつ 小数第3位が4未満)
   + P(U の 小数第1位が3 かつ 小数第2位が5 かつ 小数第3位が4未満 かつ 小数第4位が6未満)
 
 =   P(0.00 ≦ U < 0.05) + P(0.10 ≦ U < 0.15) + P(0.20 ≦ U < 0.25)

   + 10 * ( P(0.0500 ≦ U < 0.0506) + P(0.1500 ≦ U < 0.1506) + P(0.2500 ≦ U < 0.2506) )

   + P(0.300 ≦ U < 0.304) + P(0.310 ≦ U < 0.314) + P(0.320 ≦ U < 0.324)
   + P(0.330 ≦ U < 0.334) + P(0.340 ≦ U < 0.344)

   + P(0.3500 ≦ U < 0.3506) + P(0.3510 ≦ U < 0.3516) 
   + P(0.3520 ≦ U < 0.3526) + P(0.3530 ≦ U < 0.3536)
 
 = 3 * 0.05 + 10 * 3 * 0.0006 + 5 * 0.004 + 4 * 0.0006
 
 = 0.3 * 0.5 + 0.3 * 0.06 + 0.5 * 0.04 + 0.04 * 0.06

 = 0.3 * 0.56 + 0.04 * 0.56

 = 0.34 * 0.56

ではこれを任意の桁数に一般化して……えっと……あれ……一般の場合も上の要領で導出しようとすると煩雑になりそうですね……。

こうしたら?

0 \leqq a, b \leqq 1 を小数点以下の桁数が有限である小数とする。このとき、小数点以下の桁数が大きい方の桁数を N として d = 10^{-N} とする。また、2つの引数の小数以下を交互につなぐ関数  f(x, y) を定義する。例えば、 f(0.1234, 0.56) = 0.1526304 である。
このとき、 \mathrm{P}(U_1 < a, \, U_2 < b) は以下となる。
 \begin{split} \mathrm{P}(U_1 < a, \, U_2 < b) &= \mathrm{P} \left(  \bigcup_{n=0}^{a/d-1} \bigcup_{m=0}^{b/d-1} ( nd \leqq U_1 < nd + d, \, md \leqq U_2 < md + d ) \right) \\ &= \mathrm{P} \left(  \bigcup_{n=0}^{a/d-1} \bigcup_{m=0}^{b/d-1} \bigl( f(nd, md) \leqq U < f(nd, md) + d^2 \bigr) \right) \\ &= \sum_{n=0}^{a/d-1} \sum_{m=0}^{b/d-1} \mathrm{P} \bigl( f(nd, md) \leqq U < f(nd, md) + d^2 \bigr) \\ &= (a/d)(b/d)d^2 \\ &= ab \end{split}

なんと。すっきりしましたね。ああ、そのように区切っておけば、相当する U 上の区間は小数点以下 2N 桁まで固定され、それより右の桁が任意であるような、幅 d^2 の区間に一律になりますね。そうか、私は、「U_1 の何桁目が何で U_2 の何桁目が何である」という境界で区切っていましたが、そのためにU上の区間の大きさはばらついたりあまつさえ分裂したりしていたので、できうる限り細かく区切っておいた方が扱いやすかったんですね。

小数点以下の桁数が無限でも成り立つことを後でかく → PDF にかいたが副部長のアプローチでは結局かいていない

雑記: ルーヴァン法とライデン法の話

お気付きの点がありましたらご指摘いただけますと幸いです。

まとめ
  • グラフをコミュニティに分割する手法に、ルーヴァン法と、それを改良したライデン法がある。
  • ルーヴァン法もライデン法もコミュニティ分割のモジュラリティ (や Constant Potts Model のハミルトニアン) を最大化しようとする。モジュラリティは同じコミュニティのノードがつながっていることに報酬、つながっていないことにペナルティを課す指標と解釈することもできる。ただし報酬やペナルティの度合いがノードの次数が大きさ (生えている枝の多さ) に依存する。
  • ルーヴァン法はシングルトンパーティションから開始し、ノードをランダムな順序で回っていってモジュラリティが大きくなるのであればノードを別コミュニティに移動していくことを周回し、それが終わったら1コミュニティが1ノードになるようにグラフを集約し、また同じことを繰り返す。
  • ライデン法もシングルトンパーティションから開始し、ノードをランダムな順序で回っていってモジュラリティが大きくなるのであればノードを別コミュニティに移動していき (ただし移動したら近隣の別コミュニティのノードを必ず後で回るようにし、必ずすべてのノードを回すようにする)、それが一周したら先にパーティションを「精製」してから1コミュニティが1ノードになるようにグラフを集約し、同じことを繰り返す。

グラフのコミュニティ分割手法の一つである Louvain 法 [1] は、モジュラリティなる指標を最大化するようにグラフをコミュニティ分割するのですね。それでとても高速であると。ちなみに原論文 [2] の著者陣に Louvain さんという方はいらっしゃらなくて不思議に思ったのですが、著者陣の所属大学がベルギーのフランス語圏のルーヴァン・カトリック大学であることが手法名の由来なのですね。であればカタカナ表記は「ルーヴァン法」とすべきですね。わたしは初見で「ルーヴェン法」だと思いそうよんでしまっていたのですが、ルーヴェンカトリック大学と表記すると分割元であるオランダ語圏の方の大学を指すようですので。それにしても、オランダ語話者とフランス語話者との対立で大学が分割されるという歴史的経緯があったのですね……グラフだけでなく大学まで分割されていたとは……。

閑話休題、グラフのモジュラリティとは何なのでしょうか。ルーヴァン法が如何に高速であるといっても、モジュラリティがグラフの分割のよさを示す指標として納得できるものでなければ採用するわけにはいきません。モジュラリティの定義は [3] などにありますので、さしあたり適当なグラフの適当な分割を考えて計算してみましょう。例えば下図のようになりますね。

ルーヴァン法の Python パッケージ [4] が出す値と合致するかどうかも確認してみましょう。上図と同じ 0~6 のノードを登録し、上図と同じ個所にエッジを張ると、最適な分割は上図の青とオレンジの分け方になりますね (ノード 0~2 がコミュニティ 0、ノード 3~6 がコミュニティ 1)。この分割のモジュラリティは……上図の値と一致していますね。

import networkx as nx
import community

G = nx.Graph()
G.add_nodes_from([0, 1, 2, 3, 4, 5, 6])
G.add_edges_from([
    (0, 1), (0, 2), (1, 2), (2, 3), (3, 4), (3, 5), (3, 6), (4, 5), (5, 6)])
partition = community.best_partition(G)
print(partition)
modularity = community.modularity(partition, G)
print(modularity)
print(
    6.0 / 18.0 - (7.0 / 18.0) * (7.0 / 18.0)
    + 10.0 / 18.0 - (11.0 / 18.0) * (11.0 / 18.0)
)

{0: 0, 1: 0, 2: 0, 3: 1, 4: 1, 5: 1, 6: 1}
0.3641975308641975
0.3641975308641974

モジュラリティが一致したのはいいのですが、問題はこれがよい分割の指標かどうかです。モジュラリティは「コミュニティ内部の枝の本数の期待値 (枝が生える元を不変としてつながる先がランダムであった場合の期待値) からの差」といった指標になっていますが、この期待値の計算においては、自分自身への枝も、同じノード間の 2 本以上の枝も許容されるし、枝が枝毛になる (枝を張る先を選択するときに既にそこに枝が張られていることを考慮しないという意味) ことも許容されますよね。これでは基準である期待値がめちゃくちゃなような……ただまあ、[3] には in a large random network, the number of self-loops and multi-edges is vanishingly small. とあるのでそこは置いておきますか……。

しかし、やはり個人的には「期待値」を考えるのはわかりにくいです……つながる先のみがランダムである状態に意味を見出しづらいですし……。コミュニティ分割のよさはもっと素朴に「ノードどうしが同じコミュニティなら枝があってほしい (あったら加点、なかったら減点)」「ノードどうしが違うコミュニティなら枝があってほしくない (あったら減点、なかったら加点)」といったものであってほしいですが、モジュラリティの定義をそれっぽく書き下すと以下でしょうか。ここで、n はノード数、m はエッジ数 (両方向からカウントしたものではなく直感的な本数)、k_v はノード v につながっているエッジ数 (ノードの次数) です。

  • すべてのノード対  (v, w) について以下を足して 2m で割ったものがモジュラリティです。
    •  (v, w) が同じコミュニティで、枝がないとき、 -k_v k_w/(2m) のペナルティです。
    •  (v, w) が同じコミュニティで、枝があるとき、 1-k_v k_w/(2m) の報酬です。
    •  (v, w) が違うコミュニティであるときは報酬もペナルティもありません。
これだと対称でないので以下のようにかくこともできます ( \sum_v k_v = 2m を利用します)。しかしかえってわかりにくいかもしれません。
  • すべてのノード対  (v, w) について以下を足して 2m で割ったものがモジュラリティです。
    •  (v, w) が同じコミュニティで、枝がないとき、 -k_v k_w/(4m) のペナルティです。
    •  (v, w) が同じコミュニティで、枝があるとき、 1/2-k_v k_w/(4m) の報酬です。
    •  (v, w) が違うコミュニティで、枝がないとき、 k_v k_w/(4m) の報酬です。
    •  (v, w) が違うコミュニティで、枝があるとき、 -1/2+k_v k_w/(4m) のペナルティです。

こうしてみると、例えば、もしある SNS 上で友達関係にある人たちをコミュニティ分割したいのだとしたら、 k_v が大きい人はたくさんの友達がいる人なので、そんな友達が多い A さんと友達である B さんを同じコミュニティと判定しても報酬は少なめで、違うコミュニティと判定してもペナルティは控えめになりますね。そんなにたくさん友達がいたらどのコミュニティに所属していても不思議でないという感じでしょうか。逆に、友達が少ない人に対する判定は厳しくなりますね。どうも個々人の友達の人数=各ノードの次数への依存度が大きい指標である気がしますが……。

def modularity(partition, G):
    mo = 0.0
    m = float(len(G.edges))
    for v in G.nodes:
        for w in G.nodes:
            if partition[v] == partition[w]:  # 同じコミュニティならペナルティか報酬
                mo -= G.degree[v] * G.degree[w] / (2.0 * m)
                if ((v, w) in G.edges) or ((w, v) in G.edges):
                    mo += 1.0  # つながっていたら報酬
    return mo / (2.0 * m)

def modularity2(partition, G):
    mo = 0.0
    m = float(len(G.edges))
    for v in G.nodes:
        for w in G.nodes:
            if partition[v] == partition[w]:  # 同じコミュニティならペナルティか報酬
                mo -= 0.5 * G.degree[v] * G.degree[w] / (2.0 * m)
                if ((v, w) in G.edges) or ((w, v) in G.edges):
                    mo += 0.5  # つながっていたら報酬
            else:  # 違うコミュニティでもペナルティか報酬
                mo += 0.5 * G.degree[v] * G.degree[w] / (2.0 * m)
                if ((v, w) in G.edges) or ((w, v) in G.edges):
                    mo -= 0.5  # つながっていたらペナルティ
    return mo / (2.0 * m)

print(modularity(partition, G))
print(modularity2(partition, G))

0.3641975308641976
0.36419753086419754

案の定 [3] をみると、グラフが大きい場合、各コミュニティ内が密にエッジで結束している場合でも、コミュニティどうしが統合されてしまうという問題点が指摘されていますね。これは例えば、ある漫画が好きな人たちの密なコミュニティと、あるゲームが好きな人たちの密なコミュニティが形成されているにもかかわらず、前者に所属する C さんと後者に所属する D さんが友達どうしであり、かつ両者ともに友達が少なめであるせいで両者を同じコミュニティにしないことのペナルティが相対的に大きくなり、コミュニティを統合してしまうといった状況でしょうか。

しかし、ルーヴァン法の記事 [1] をみると、Leiden 法ではこれが解消されているというような記述がありますね。Leiden 法もモジュラリティベースのコミュニティ分割手法らしいですが、Leiden 法は純粋なモジュラリティ最大化をしないのでしょうか? モジュラリティ最大化の範囲でパーティションがよくなるのでしょうか? Leiden 法の原論文は [5] ですね。ルーヴァン法と同じ命名則で著者陣の所属が Leiden 大学なんですね。であれば以降ライデン法と表記しましょう。しかし、ルーヴァン法に代わるライデン法がオランダの大学発とは、「From Louvain to Leiden:」といった挑戦的にもみえるタイトルといい、因縁を感じるような……どうか分割するのはグラフだけにして……。

とりあえずライデン法の Python パッケージ [6] を使用してみると、グラフを igraph 形式に変換すればコミュニティ分割してくれますね。簡単な例なので結果も変わりません。

import networkx as nx
import igraph as ig
import leidenalg as la

ig_graph = ig.Graph.TupleList(G.edges(), directed=False)
partition = la.find_partition(ig_graph, la.ModularityVertexPartition)
print(partition)

Clustering with 7 elements and 2 clusters
[0] 3, 4, 5, 6
[1] 0, 1, 2

それで肝心のライデン法がどうよいのかですが……ライデン法の原論文 [5] をみていくとそもそもルーヴァン法の疑似コードもありますね。これをまとめると以下でしょうか。なるほどシンプルですね。

  1. まず初期状態のパーティションはグラフ内のノード1つ1つを1コミュニティにします (シングルトンパーティション)。
  2. ノードをランダムな順に回って「ノードを別のコミュニティに移動したらモジュラリティが大きくなるか」を調べ、大きくなるようならパーティションを変更します。モジュラリティが大きくなったら次の周回に進み、大きくならなければ終わります。
  3. 2. の移動結果に基づき、1コミュニティが1ノードになるようにグラフを集約し、パーティションをシングルトンパーティションにし、2. に戻ります。2. でパーティションの変更が発生していなければこれで終わります。
対してライデン法では以下ですね。refinement が理解し切れていません。ただここは色々な実装がありえてそれによってコミュニティ分割に理想的な性質をもたせられるような感じにみえますが……?
  1. まず初期状態のパーティションはグラフ内のノード1つ1つを1コミュニティにします (シングルトンパーティション)。
  2. ノードをランダムな順にキューに入れてキュー内のノードを回って「ノードを別のコミュニティに移動したらモジュラリティが大きくなるか」を調べ、大きくなるようならパーティションを変更します。ただしこのとき変更が発生したら、その隣接ノードであって異なるコミュニティであるノードをキューに追加して後で必ず回るようにします。キュー内のノードを回り切ったらこの手順を終わります。
  3. パーティションの「精製 (refinement)」をします。2. の結果のコミュニティをそのまま採用するのではなく、そのコミュニティの中でよくつながりあっているところがコミュニティとして採用されます。なので 2. の結果のコミュニティはここで分割され得ます。
  4. ここまでの結果に基づき、1コミュニティが1ノードになるようにグラフを集約し、パーティションをシングルトンパーティションにし、2. に戻ります。2. と 3. でパーティションの変更が発生していなければ終わります。

終わり

雑記: AWS にカフェ (アプリケーション) を開店準備するまで

人生で、新しく配属された組織が AWS の知識を前提にしていることがあると思います。でも用語が多いと思います。なので同じ境遇の方のためにカフェに喩えました。お気づきの点がありましたらご指摘いただけますと幸いです。
なお、長くなってしまったのでパブリックサブネットの NAT ゲートウェイやロードバランサの話はないです。

まとめ

  • VPC: あなたが初めて AWS 国にカフェ (アプリケーション) を建設するとき、まずつくる町です (つくらなくてもデフォルトでつくられますが)。16~65536 区画 (IP アドレス数) の広さの町をつくることができます。
    • サブネット: 町の中の丁目です。なお、各丁目の 2 区画目には自動的にタクシー乗り場 (VPC ルータ) が建設されます。
    • ルートテーブル: 各タクシー乗り場に設置されている、「そこからある目的地 (送信先) に行くとき、次の経由地 (ターゲット) はどこか」を示すガイドブックです。ここに登録がない目的地には行けません。
  • EC2 インスタンス: カフェの建物 (仮想マシン) です。建てたい丁目 (サブネット) に、建てたい構造・広さ (Amazon マシンイメージ・インスタンスタイプ) の建物を建設します。
    • セキュリティグループ: 建物のセキュリティシステムに登録するルールです。
      • インバウンドルール: 建物外から建物内に入ってくる人の通行許可です。
      • アウトバウンドルール: 建物内から建物外に出張に行く人の出張許可です。
    • IAM ロール: その建物から AWS 国の公共機関を利用してよいかの許可です。
  • SSM AgentAWS 国の公共サービスとして、建設した建物についてくる管理人ロボです。建設したカフェの内装工事 (開発作業) をするとき、インバウンドルールに自分を追加して自分で建物に入ってやる方法 (SSH 接続など) も、建物にいる管理人ロボに代わりにやらせる方法 (セッションマネージャー経由の接続) もあります (通常後者の方法を取ります)。
  • Systems Manager (SSM): 管理人ロボを派遣している AWS 国の公共機関です。あなたがこの機関の受付窓口 (セッションマネージャー) に内装工事の指示を送り、他方、管理人ロボが定期的にカフェがある丁目の支所から秘密の地下通路でこの機関に指示を確認しに出張することで (ポーリング)、便利で安全に内装工事ができます。
  • VPC エンドポイント: その丁目で AWS 国の公共機関を利用したいとき、その丁目内に誘致する支所です。なんと秘密の地下通路 (PrivateLink) で本部とつながっています (インターフェイス型の場合)。

  1. IPアドレスとCIDR表記の理解:AWSでのネットワーキング - Qiita:人はまず VPC を作成しようとして「10.0.0.0/16 って何」となると思います。このページをみて事なきを得ました。
  2. 【VPC/サブネット】AWSによる予約IPアドレスについて - エムトラッドブログ:1. の記事に「最初の4つと最後の1つのIPアドレスAWSによって予約されています」とあってとても気になると思います。このページをみて事なきを得ました。
  3. ステップ 2: VPC エンドポイントを作成する - AWS Systems Manager:最初プライベートサブネットをルートテーブルでインターネットゲートウェイにつないでしまっていたので (台無し)、たぶんそのせいでこの設定をしなくてもセッションマネージャーに接続できていました。わかりません。正しくはこの設定をするはずです。この設定をしてセッションマネージャーで接続できるようになるのに少しタイムラグがあって焦りました。
  4. VPCエンドポイント ってなんのこと? - Qiita:本当になんのことなので助かりました。
  5. セッションマネージャー越しにSSHアクセスすると何が嬉しいのか | DevelopersIO:セッションマネージャー経由時にどうなっているのかわからなかったので参考にしました。

以下、ストーリー本編です。

町 (VPC) をつくる
あなたは AWS 国の入国ビザ (アカウント) を得ました。あなたは AWS 国にカフェを開業したい (アプリケーションをたてたい) です。そのためには、まず町 (VPC) をつくります。VPC ダッシュボードに移動し、「VPC を作成」を押下します。

  • 「作成するリソース」で「VPC など」を選択します。
    • 本当にただ町をつくるだけなら「VPC のみ」でよいですが、町をつくるときにその中に何丁目をつくるかと、各丁目のタクシー乗り場から出るタクシーはどこに行けるのか書き込むガイドブックもつくっておくと便利だと思います。
  • IPv4 CIDR ブロック」であなたの町の大きさを指定します。ただし、以下の決まりがあります。
    • 町の大きさは 16 区画~ 65536 区画 (10.0.0.0/28 ~ 10.0.0.0/16) の広さを指定することになっています。この区画ごとに住所 (IP アドレス) があります。なお、10.0.0.0/n という表記は 2 の 32-n 乗の区画数を表します。
    • ただし、町の中をさらに丁目 (サブネット) に分ける場合は、1 つの丁目が最低 16 区画必要です。町全体が 1 つの丁目でも構いませんが、以下のような場合には丁目を分けます。
      • 例えば、あなたの町の中に「町の外から直接入ってこれる丁目 (パブリックサブネット)」と「そうでない丁目 (プライベートサブネット)」があるような設計にしたいならば、そこで丁目を分けます。
      • 例えば、停電でカフェが全く営業できなくなるリスクに備えて地域ごとに依存する発電所を分けてカフェを分散したい (複数データセンターにアプリケーションをデプロイしたい) ならば、ある一つの発電所に依存する丁目 (アベイラビリティゾーン) ごとに丁目を分けます。これは設計思想というか仕組み上分けざるをえません。
  • つまり、あなたの町を、アベイラビリティゾーン数が 2 で、それぞれにパブリックサブネットとプライベートサブネットがあるようにしたいなら、丁目の数は 4 になり、ゆえに町全体の大きさは最低 64 区画必要です。
  • あなたは防犯上町の外から直接入れない丁目にカフェを建設したく、また停電時にまで営業を続行したい事情はなかったので、町の大きさを 32 区画 (10.0.0.0/27) とし、最初の 16 区画 (10.0.0.0/28) を 1 丁目とし、次の 16 区画 (10.0.0.16/28) を 2 丁目とし、それぞれパブリックサブネット、プライベートサブネットとして運用することにしようとしました。
    • なお、各丁目の最初の 4 区画と最後の 1 区画には AWS 国の決まりで自動的に公共施設が建てられるなどします (以下)。なので、それらの区画にはあなたのカフェは建設できません。
      • 最初の区画は、そこの住所 (IPアドレス) が特別な意味をもつ (その丁目の識別に利用される) ので (ネットワークアドレス)、そこには慣例的に施設を建設しません。
      • 2 番目の区画には、タクシー乗り場 (VPC ルータ) が建設されます。なお、AWS 国で移動するときは必ずタクシー乗り場からタクシーに乗らなければならず、タクシー乗り場に設置されているガイドブック (ルートテーブル) にない目的地には行けません。ルートテーブルについてはこの記事の最後の方が再度触れます。
      • 3 番目の区画には、AWS 国の各種公共サービス機関 (AWS リソース) の住所を調べられる案内所 (Amazon DNS サーバ) が建設されます。
      • 4 番目の区画はなんか念のため施設を建設することが禁止されています。
      • 最後の区画は、そこの住所がその丁目内のすべての区画の住所のエイリアスになるという特殊な意味をもつので (ブロードキャストアドレス)、施設は建設できません。

建物 (EC2 インスタンス) を建てる
あなたの町ができたらそこにカフェの建物 (EC2 インスタンス) を建てます。EC2 ダッシュボードに移動し、「インスタンスを起動」を押下します。

  • Amazon マシンイメージ」「インスタンスタイプ」は建物の構造と広さのようなものです。あなたのカフェで利用したい設備が導入できる or 既に備わっている構造を選び、その設備に期待する価値を発揮できるくらいの広さを選ぶと思います。これはあなたのカフェで提供したいサービスや、見込んでいる来店客数に依存します。
  • 「ネットワーク設定」の「VPC」「サブネット」は建物を建てる町・丁目です。先ほどつくった VPC とその中のプライベートサブネットを選びます。
  • 「ネットワーク設定」の「セキュリティグループ」は建物のセキュリティシステムに登録するルールです。後からでも変更できます。
    • 「インバウンドルール」は「建物外から建物内に入ってくる人の通行許可」です。「お店のこの入口に (ポート) 、この住所からやって来た (ソース)、こんな作法 (プロトコル) の人なら通してよいです」というようなルールを複数登録できます。
      • 例えば、カフェの内装工事 (開発作業) のために自分も建物に入りたい場合、自分 (の IP アドレス) が作業員として (TCP 接続で) 作業員入口 (22 番ポート) から入ってくることを許可するルールを追加します。ただし、実際には設計思想上プライベートサブネットにこのようなルールは追加せず、建物に公共サービスとして派遣されている管理人ロボ (SSM Agent) に代わりに作業させます。
      • また、カフェの開店前に自分がお客様としてお店のサービスを確認したい場合、自分 (の IP アドレス) がお客様入口 (例えば 5000 番ポートへの TCP 接続) から入ってくることを許可するルールも追加しておきます。ただし、実際には設計思想上プライベートサブネットにこのようなルールは追加せず、同じ町内のパブリックサブネットのロードバランサを通って来た人だけを許可します。
    • 「アウトバウンドルール」は「建物内から建物外に出張に行く人の出張許可」です。
      • デフォルトではどこにでも出張に行けます。あなたがカフェに必要な資材をあらゆる場所から調達したいなら、どこにでも出張に行けるようにした方がよいと思います。そうでなく、「この工場のコーヒー豆の仕入れのみ許可する」などとしたいときはそのようなルールを追加すると思います。
  • 「高度な詳細」内にある「IAM インスタンスプロフィール」には「IAM ロール」を設定します (両者の違いはここでは割愛します)。後からでも変更できます。「IAM ロール」とは、その建物から AWS 国の公共機関を利用してよいかの許可です。
    • 例えば、その建物の管理人ロボ (SSM Agent) に代わりに内装工事をさせる場合、管理人ロボが派遣元の公共機関 (SSM) に工事指示を確認しに行くことを許可する IAM ロール (AmazonEC2RoleforSSM) を設定する必要があります。

建物の管理人ロボ (SSM Agent) に内装工事指示を出す
建物が建ったら早速内装工事 (開発作業) をしたいと思います。例えば、インバウンドルールに自分を追加して自分で建物に入って工事することができます。ただ、この方法だとルールや鍵 (SSHキー) の管理が面倒だし、もしあなたのカフェに共同運営者が加わったらルールを追加したり鍵を渡したりがやっぱり面倒だし、そもそも、「カフェがある丁目は町の外から直接入れないようにしよう」としていたのに自分が直接入っていては設計思想と反しています。

なので、往々にして建設した建物に常駐している管理人ロボ (SSM Agent) に作業してもらいます。AWS 国で建てた建物にはなんと公共サービスとして勝手にこのロボが付いてきます。このロボはあなたの身代わりとしてあなたそっくりに動いてくれます。

しかし、疑問があると思います。このロボにどうやって指示を送るのでしょうか。あなたがロボに会いに行くならば、「カフェがある丁目は町の外から直接入れない」に反すると思います。ロボに会いに来てもらうにせよ、ロボは指示通りに動くのだから、実質「直接入れない」に反しています。

しかし、ロボは公共機関 (SSM) から派遣されているので、特別で安全な経路で町の外に情報を取りに行くことができます。具体的に、ロボに作業してもらうためには、あなたのカフェがある丁目内に SSM の支所 (VPC エンドポイント) を誘致します。その支所はなんと秘密の地下通路 (PrivateLink) で本部でつながっています。ロボはここを通って、あなたが本部の窓口 (セッションマネージャー) に出した指示を定期的に確認しに来ることができます。

  • 再び VPC ダッシュボードに移動します。やりたいことは公共機関の支所 (VPC エンドポイント) を誘致することなのですが、支所のセキュリティシステムにも、あなたのカフェからロボが指示を確認しにやって来ることを許可させないといけません。そこで、先にセキュリティシステムに登録するルール (セキュリティグループ) を作成します。サイドバーから「セキュリティグループ」を選択、「セキュリティグループを作成」を押下し、インバウンドルールに、「タイプ=HTTPS、 ソース=あなたのカフェのセキュリティグループ (あなたのカフェの IP アドレスでもよいがセキュリティグループによる指定が推奨されている)」を追加します。
  • いよいよ支所を追加します。サイドバーから「エンドポイント」を選択します。画面右上の「エンドポイントを作成」を押下し、サービスの検索フォームに .ssm と入力することで com.amazonaws.[リージョン名].ssm を探し当て選択します。続けてあなたのカフェの VPC とサブネットを選択し、最後に「セキュリティグループ」でさっきつくったセキュリティグループを選択してエンドポイントを作成します。
  • com.amazonaws.[リージョン名].ssmmessages, com.amazonaws.[リージョン名].ec2, com.amazonaws.[リージョン名].ec2messages に対しても同様に作成する必要があります。個々の支所の役割は割愛します。

ここまで作業して、EC2 インスタンスの IAM インスタンスプロフィールに AmazonEC2RoleforSSM が含まれていれば、EC2 インスタンスの「接続」を押下しセッションマネージャータブから接続できるようになっています (接続できない場合はエンドポイント作成からタイムラグがあるので少し待ってみます)。接続したコンソールでカフェの内装工事 (開発) をします。カフェに直接入って工事をしているような感覚ですが、ストーリーの都合上、これは機関の窓口でロボに指示を送っているのだと思います。

町の外部 (インターネット) から資材を搬入できるようにする

(あなたがまだサブネットのルートテーブルを適切に設定していない場合) 工事を始めてすぐに、外部から資材を搬入できない (パッケージをインストールできない) ことに気づきます。アウトバウンドルール上はどこにでも出張しに行けるようになっているのにです。それは、「この住所に資材を取りに行こう」として丁目のタクシー乗り場に行っても「そこの住所に向かう方法が登録されていないです」となっているためです。タクシー乗り場にあるガイドブックに目的地を登録する必要があります。

  • ルートテーブル: 各丁目のタクシー乗り場に設置されている、「その丁目からある目的地 (送信先) に行くとき、次の経由地 (ターゲット) はどこか」を示すガイドブックです。ガイドブックに登録されていない目的地には行けません。その町内の住所については「町内なので直接向かってください (local)」と必ず登録されています (削除することはできません)。町外の住所にも行けるようにしたい場合は、例えば町外のすべての住所 (0.0.0.0/0) について「次は『インターネットゲートウェイ』タクシー乗り場に向かってください」と登録します。そうすると、そこからまた町の外へ向かう道路 (インターネット) にガイドされることになります。
    • ただし、プライベートサブネットのルートテーブルでは、直接インターネットゲートウェイに向かわせるのは設計思想に反するので、NAT ゲートウェイに向かうようにします。

完成したカフェに訪問してみる

Elastic IP アドレスを EC2 インスタンスに紐づけ、インバウンドルールでアプリケーションがたっているポートへの自分の IP のアクセスを許可すれば仕上がりを確認できます。ただ、プライベートサブネットにパブリックな Elastic IP アドレスを紐づけるのはもう台無しです。実際には、パブリックサブネットにロードバランサを設置してそこを通ってアプリケーションにアクセスするようにします。

雑記: 自分のパッケージに Sphinx + Read the Docs によるドキュメントを用意する

例えば MoviePy パッケージには Sphinx によるドキュメントが用意されています。
 User Guide — MoviePy 1.0.2 documentation
そうすると自分のパッケージにもそのようなドキュメントを用意したくなると思います。なので、MoviePy 同様に Sphinx ツールを利用し、ホスティングサービスに Read the Docs を利用します。参考文献 1. の Sphinx-RTD チュートリアルにしたがいます。そうするとなんやかんやで以下のようなドキュメントができます。
 Welcome to cookies_utilities’ documentation! — cookies_utilities 0.0.4 documentation

なお、この記事に対応するファイル一式はこちらですが、ドキュメント追加前との差分は以下の色が付いたディレクトリ・ファイルです(水色は新規作成、緑色は自動生成、ピンク色は自動生成したものを編集、オレンジ色は既存ファイルを編集)。

cookies_utilities/
├── .readthedocs.yaml  # 新規作成する
├── docs/  # 新規作成して直下で sphinx-quickstart コマンドを叩くと以下のファイルが自動生成する├── make.bat├── Makefile├── build/└── source/├── conf.py  # html_theme = 'sphinx_rtd_theme' に変更し autodoc の設定をする├── index.rst  # ドキュメントのトップページなので適宜編集する└── cookies_utilities.rst  # sphinx-apidoc コマンドで生成されるので適宜編集する
├── pyproject.toml  # 適宜 Python バージョンを RTD に対応させる、適宜 RTD へのリンクを追加する
├── LICENSE
├── README.md
├── src/
│   └── cookies_utilities/
│       ├── __init__.py
│       └── stopwatch.py  # 適宜 Docstrings をかく
└── tests/
    └── test_stopwatch.py

  1. A “How to” Guide for Sphinx + ReadTheDocs — Sphinx-RTD-Tutorial documentation
  2. autodoc - Sphinx error: Unknown directive type "automodule" or "autoclass" - Stack Overflow
    • Sphinx がコードコメントを拾ってくれずに ERROR: Unknown directive type "autoclass" というときは conf.py にて extensions = ['sphinx.ext.autodoc'] とせよといってくれています。
  3. Search page empty · Issue #998 · readthedocs/sphinx_rtd_theme · GitHub
    • Sphinx + RTD でドキュメントを生成すると、デフォルトで index.html からリンクされる Search Page が空になります(ローカルでもサーバにあげても)。が、これは既知の未解決のバグであるようです。なお、検索欄自体はサイドバーにあるのでドキュメント内検索はできます。

sphinxsphinx-rtd-theme をインストールする
参考文献 1. にしたがい以下をインストールします。

pip install sphinx
pip install sphinx-rtd-theme

参考文献 1. では simpleble なるパッケージをチュートリアルの題材にしており、このパッケージが参照する bluepy のインストールも要求していますが、このパッケージは Windows に対応していないので入れないですというか入れられないです。ただ自作パッケージの依存パッケージも sphinx と同じバージョンの pip で入れるように注意があるので将来に機会があればそれにしたがいます。今回の自分のパッケージは依存パッケージがないので対応することはないです。

docs ディレクトリをつくる
自分のパッケージリポジトリをクローンして docs ディレクトリをつくります。

cookies_utilities/
├── docs/ <-- New!
├── pyproject.toml
├── LICENSE
├── README.md
├── src/
│   └── cookies_utilities/
│       ├── __init__.py
│       └── stopwatch.py
└── tests/
    └── test_stopwatch.py

つくった docs ディレクトリに移動して sphinx-quickstart コマンドを実行します。色々質問されるので以下のように答えます(チュートリアルではもっと色々質問が続いていたのですが私の場合はここで終わってしまいました)。

$ mkdir docs
$ cd docs
$ sphinx-quickstart
> ソースディレクトリとビルドディレクトリを分ける(y / n) [n]: y
> プロジェクト名: cookies_utilities
> 著者名(複数可): CookieBox26
> プロジェクトのリリース []: 0.0.4
> プロジェクトの言語 [en]: en

そうすると docs 以下にファイルやディレクトリが生成されます。

cookies_utilities/
├── docs/
│   ├── make.bat
│   ├── Makefile
│   ├── build/
│   └── source/
│       ├── conf.py
│       └── index.rst
(略)

生成された source/conf.py に以下の変更をします。

  • html_theme = 'sphinx_rtd_theme' にする。
  • コード中のコメントからドキュメントを自動生成したいときはさらに以下の 2 点を対応する。
    • extensions = ['sphinx.ext.autodoc'] とリストに加える(チュートリアルでは sphinx-quickstart 実行時にこれも質問されていたのですがなぜか私は質問してもらえなかったので手動で加えます)。
    • ファイルの先頭に以下を追記する。
      import os
      import sys
      sys.path.insert(0, os.path.abspath('../../src/cookies_utilities/'))

コードにコメントを記述する
Sphinx にドキュメントを自動生成してもらえるように Sphinx docstring 形式でコードにコメントをかきます。
 Writing docstrings — Sphinx-RTD-Tutorial documentation

私は以下のようにかきました。Summary と param の間には空行が必要です(さもないとすべて Summary になってしまいます)。

class Stopwatch:
    """ Stopwatch for measuring processing time.
    """
    def __init__(self):
        self.cache = []

    def press(self, key=''):
        """ Press the stopwatch.

        :param key: The idenficator for the time, defaults to ''
        :type key: string, optional
        """
        self.cache.append((key, time.perf_counter()))

reStructuredText ファイルを整備して HTML ファイルにビルドする

ドキュメントはまず reStructuredText ファイル(.rst ファイル)一式として用意して、その後に HTML ファイル一式にビルドすることになります。まず、さっきコメントをかいたのをドキュメントにしたいので docs 以下で以下を実行して ./source/modules.rst, ./source/cookies_utilities.rst を生成させます。

sphinx-apidoc -o ./source ../src/cookies_utilities

このうち ./source/modules.rstチュートリアルにあるようにモジュールがたくさんあるとき便利なようですが、今回の自分のパッケージには stopwatch.py しかないので無視します。./source/cookies_utilities.rst の方も cookies_utilities.Stopwatch の説明さえあればいいのでチュートリアルを参考にかなり編集してしまいます。

Documentation
=============

The ``cookies_utilities.Stopwatch`` class
*****************************************

.. autoclass:: cookies_utilities.Stopwatch
   :members:
   :undoc-members:
   :show-inheritance:

./source/index.rst からリンクを張らないと意味がないのでこちらのファイルも編集します。自動生成された状態では cookies_utilities's となってしまっているので最後の s を取るなどの手直しもします。

Welcome to cookies_utilities' documentation!
=============================================

.. toctree::
   :maxdepth: 2
   :caption: Contents:

   cookies_utilities.rst

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

.rst ファイルの手直しができたら docs 以下で以下を実行して HTML ファイル一式をビルドします。

./make.bat clean  # Windows でない人は make clean
./make.bat html  # Windows でない人は make html

ここで ERROR: Unknown directive type "autoclass" といわれるようなら conf.py において extensions = ['sphinx.ext.autodoc'] としてあるか確認します(参考文献 2.)。また、No module named 'cookies_utilities' といわれて自分のパッケージを認識してくれないようなら一階層上に戻って手動でインストールしてしまいます。setup.py 式でないので自動でインストールしてくれないのかよくわかりません。

cd ..
pip install -e .
cd docs
./make.bat clean
./make.bat html

そうすると docs/build/html/index.html に素敵なページが作成されます。コードコメントが自動でドキュメント化されているのもわかります。自分のパッケージ内のクラスやメソッドの Index も生成されています。

Search Page はローカルでは機能しませんが問題ありません → と思いましたが後にパブリッシュしても機能していませんでした。何ならチュートリアルの Search Page も機能しておらずこれはバグのようです(参考文献 3.)。なので私はリンクを消しました。

GitHub リポジトリに push し Read the Docs にパブリッシュする(要 .readthedocs.yaml
以下のファイルを GitHub リポジトリに push します。

docs/make.bat
docs/Makefile
docs/source/conf.py
docs/source/cookies_utilities.rst
docs/source/index.rst
docs/source/modules.rst
src/cookies_utilities/stopwatch.py

そうしたらリポジトリを Read the Docs から参照します。まずは Read the Docs にアカウント登録します。GitHub アカウントと連携すると自分のリポジトリ一覧が Read the Docs からみられるので cookies_utilities を選択します。ただ選択しようとすると「リポジトリ直下に .readthedocs.yaml はありますか」と確認されると思います。まだ用意していないので巷の同名のファイルを調べつつ以下のように用意します。

version: 2
sphinx:
  configuration: docs/source/conf.py
python:
  version: 3.8
  install:
    - method: pip
      path: .

python.version を 3.8 としているのは当初 3.10 にしたところ、Read the Docs 上で 3.8 までで選べとエラーになったからです。なお、.readthedocs.yaml 上で python.version を下げると pyproject.tomlproject.requires-python も連動して下げなければやはりエラーになります。

[project]
requires-python = ">=3.8"

今回の自分のパッケージは実際 Python 3.8 でもいいのでバージョンを下げましたが、本当に Python のもっと大きいバージョンを要求するパッケージのときはどうするといいのかわかっていません。そんなわだかまりはありますがともあれ自分のパッケージのドキュメントが以下のようにパブリッシュされます。
 Welcome to cookies_utilities’ documentation! — cookies_utilities 0.0.4 documentation

雑記: 自分のパッケージを TestPyPI(PyPI のテスト環境)に登録するまで―外部パッケージに依存する場合

外部パッケージに依存するパッケージを TestPyPI(PyPI のテスト環境)に登録します。先に結論をいうと、

  • コードは外部パッケージに依存しているがメタデータに依存性を記述しないという無作法なパッケージをつくることができるのはできます。ユーザはインストールを終えて実行時に必要な外部パッケージがないことに気付かされることになります。そのようなパッケージはだめだと思います。
  • なので pyproject.toml に依存性をかけばよいです。ローカルでのインストールは問題ないし開発用の依存性も指定できます。ただ、ビルドしたパッケージを TestPyPI に登録してそこからインストールすると PyPI にある外部パッケージを自動で取り入れられないと思います(ビルドツールによっては外部依存性の解決時には PyPI を向くことをできるかもしれないですが?)。「そのような外部パッケージはないです」といわれるのでいわれた端から手動でインストールする運用になりますが、無作法にするよりはましだと思います(?)。そもそもきちんと開発を進めて PyPI のほうに登録すべきだと思います。
  • なお、pytorch のような外部パッケージを自動で入れられるのかに私は興味があったはずですが、もう面倒なので「pytorch は予め入れてください」という運用にしたいです。

  1. Dependencies Management in Setuptools - setuptools 68.0.0.post20230720 documentation
    • setuptools は使用しませんが Python や Hatch のドキュメントでは optional-dependencies が何のためにあるものかわからなかったのでこれを参照しました。
  2. How to specify extra-index in a pyproject.toml for pip and pip-tools? - Packaging - Discussions on Python.org
    • pyproject.toml で複数のインデックス URL を扱えないか調べましたが扱えないと思います。
  3. Dependencies Management in Setuptools - setuptools 68.0.0.post20230720 documentation
    • 1. と同じページ内ですが PyPI and other standards-conformant package indices do not accept packages that declare dependencies using direct URLs とあります。なので PyPI 上の外部パッケージを URL ごと指定するのはローカルではできそうですが TestPyPI に登録できないと思います。

まず依存関係を考慮せずファイル一式を用意する
まず外部パッケージに依存するクラスをかいて、その他のファイルは外部パッケージを依存するときと同様にします。ファイル一式はこちらにある通りですがツリーを描くと以下です。

cookies_models/
├── pyproject.toml
├── LICENSE
├── README.md
├── src/
│   └── cookies_models/
│       ├── __init__.py
│       └── mylinear.py
└── tests/
    └── test_mylinear.py

今回 mylinear.py に MyLinear というクラスを実装しましたが実態はほぼ torch.nn.Linear です。このクラスは torch と torchinfo に依存しています。今回仮想環境でなくシステム環境で開発していますが、私のシステム環境には torch と torchinfo があるのでテストは問題なく通ります。そして、前回同様にパッケージのビルド → アップロード → インストールもやってみるとできてしまいます。インストール後は以下のように動きます。

$ python
>>> import cookies_models as cm
>>> model = cm.MyLinear(4, 3)
>>> model.summary()
=================================================================
Layer (type:depth-idx)                   Param #
=================================================================
MyLinear                                 --
├─Linear: 1-1                            15
=================================================================
Total params: 15
Trainable params: 15
Non-trainable params: 0
=================================================================

ただ、試しに手元の torchinfo と cookies_models をアンインストールした上で再度 cookies_models をインストールすると、cookies_models のインストール自体はできますがインポートできないことがわかります。torchinfo が自動でインストールされないためです。

>>> import cookies_models as cm
Traceback (most recent call last):
  File "", line 1, in 
  File "C:\Users\c-mih\AppData\Local\Programs\Python\Python310\lib\site-packages\cookies_models\__init__.py", line 1, in 
    from cookies_models.mylinear import MyLinear
  File "C:\Users\c-mih\AppData\Local\Programs\Python\Python310\lib\site-packages\cookies_models\mylinear.py", line 2, in 
    import torchinfo
ModuleNotFoundError: No module named 'torchinfo'

pyproject.toml に依存関係を記述する
インストールできてもインポートできないパッケージではあまりに無作法なので、メタデータに外部パッケージ(ここでは torch, torchinfo)への依存性を記述します。外部パッケージに依存するついでにテストも unittest ではなく pytest にします。記述したコミットが以下です。
update · CookieBox26/cookies_models@4ca9370 · GitHub

上記のコミットでは pyproject.toml に以下を追記しています。

dependencies = [
    "torch",
    "torchinfo",
]

[project.optional-dependencies]
dev = [
    "pytest",
]

project.optional-dependencies というのはオプショナルな依存性ですが、和訳しても何を指定するものかわからないですが、参考文献 1. によれば、例えばあるパッケージにおいてオプションで PDF 出力もサポートしたい場合などに利用するようです。PDF 出力を必要としない人にまで PDF 出力用の依存パッケージの導入を強いたくないので理にかなっていると思います。そして、開発用にだけつかうパッケージ(ここでは pytest)を指定するときも project.optional-dependencies を利用するのがプラクティスであるようです(要出典)。

開発中にカレントディレクトリを editable モードでインストールしてテストするときは以下のようになります。これで手元に torchinfo や pytest がないときでも自動でインストールされます( torch については実験用に削除すると再インストールに時間がかかるので自動でインストールされるか確かめていませんというか上手くいかない気がします)。

pip uninstall cookies_models  # uninstall the package if already installed
pip install -e '.[dev]'  #  install the package in editable mode
pytest  # test


ローカルでのテストも上手くいったのでパッケージングしてアップロードして、手元のパッケージをアンインストールしてからインストールしてみます。

pip uninstall cookies_models
pip uninstall torchinfo
pip uninstall pytest
pip install -i https://test.pypi.org/simple/ cookies-models==0.0.2

そうすると torchinfo というものはないといわれてインストールできないことがわかります。

ERROR: No matching distribution found for torchinfo


それで、TestPyPI には torchinfo がないので実際ないのだろうと思います。であれば前回に TestPyPI にアップロードしてある cookies_utilities なら自動でインストールしてくれるはずです。なのでそれを実験するためにかき直したものがこれです。これをパッケージングしてアップロードしてインストールすると cookies_utilities も自動でインストールしてくれることが確認できます。

$ pip install -i https://test.pypi.org/simple/ cookies-models==0.0.3
(省略)
Installing collected packages: cookies-utilities, cookies-models
Successfully installed cookies-models-0.0.3 cookies-utilities-0.0.3