Pythonで書く抽選処理 （random.sample の「重複なし」の意味を誤認識していた話）

## 前書き

とあるデータ群から、
ランダムに「当選者」を抽出する「抽選対応」を行うことになりました。
抽選元データは「ユニークキー」と「顧客名」で構成されています。

```text:sample.csv
3c0b73a5902d,一般顧客Bさん
709a8a94ccd4,一般顧客Cさん
72178ea3f377,一般顧客Dさん
3f67df63a1cb,一般顧客Eさん
9b9c6dbb9f0e,一般顧客Fさん
f5b37a9f4f94,一般顧客Gさん
aa1c91951bbd,一般顧客Hさん
4aea486a3644,一般顧客Iさん
abedb4ac7e6f,一般顧客Jさん
60e8691e2512,一般顧客Kさん
b7be8af347e7,一般顧客Lさん
8feaf44d7daa,一般顧客Mさん
6e75f4336396,一般顧客Nさん
b9be47006237,一般顧客Oさん
b031db7dd5db,一般顧客Pさん
4c29ff917494,一般顧客Qさん
516542e94f03,一般顧客Rさん
a7972229432e,一般顧客Sさん
fe8397071d2f,一般顧客Tさん
b7c55528c0b0,一般顧客Uさん
a61821fb1829,一般顧客Vさん
68c760d5472b,一般顧客Wさん
ab73ce466f67,一般顧客Xさん
d53341576571,一般顧客Yさん
ef1fc02b3eeb,優先顧客Aさん
ef1fc02b3eeb,優先顧客Aさん
20ebab769cf1,優先顧客Zさん
20ebab769cf1,優先顧客Zさん
20ebab769cf1,優先顧客Zさん
```

- 一般顧客
    - 抽選元データには1件のみ含まれる, 抽選チャンスは1回だけの顧客

- 優先顧客
    - 抽選元データには2件以上含まれる, 抽選チャンスは含まれる数だけの顧客
    - ただし当選は1回限りとする

優先顧客が重複したデータで入っていて、
重複分だけ抽選で有利にする、という要件です。


## やったこと

上記要件を対処する抽選処理を、pythonで作成しました。
pythonのランダム処理を使って対応していきます。

抽選元データに重複がある想定なので、
これをカバーする処理がないかをマニュアルで漁ります。

ザッとみた限り、こちらが利用できそう。

random
https://docs.python.org/ja/3/library/random.html

上記マニュアル内にある「random.sample」は、

**「母集団のシーケンスまたは集合から選ばれた
　長さ k の一意な要素からなるリストを返します。
　重複無しのランダムサンプリングに用いられます。」**

とあります。

上記抽選元データにある優先顧客も、
重複せず取得してくれる、という認識でええのかしら？


## それは誤認識でした

誤認識で作成したコードは以下。

````python
import csv
import random

list_lottery = []
csv_file = open("./sample.csv", "r", encoding="ms932",
                errors="", newline="")
f = csv.reader(csv_file, delimiter=",", doublequote=True,
               lineterminator="\r\n", quotechar='"', skipinitialspace=True)
for row in f:
    list_lottery.append([row[0], row[1]])
list_win = random.sample(list_lottery, 5)
print(list_win)
````

これを実行してみると、

````
python bad_lottery.py
[['6e75f4336396', '一般顧客Nさん'], 
['ef1fc02b3eeb', '優先顧客Aさん'], 
['4aea486a3644', '一般顧客Iさん'], 
['20ebab769cf1', '優先顧客Zさん'], 
['ef1fc02b3eeb', '優先顧客Aさん'], 
['ab73ce466f67', '一般顧客Xさん'],
['abedb4ac7e6f', '一般顧客Jさん'], 
['72178ea3f377', '一般顧客Dさん'], 
['4c29ff917494', '一般顧客Qさん'], 
['709a8a94ccd4', '一般顧客Cさん']]
````

当選対象者内に優先顧客Aさんが2件含まれている。
取得したデータに重複が生じています。

重複無しのランダムサンプリングじゃなかったのか・・・！


## random.sample の重複なしの正しい解釈

**「母集団のシーケンスまたは集合から選ばれた
　長さ k の一意な要素からなるリストを返します。
　重複無しのランダムサンプリングに用いられます。」**

とは、

**「配列の要素を重複取得しない」**

ということでした。

それを確認するために、
勘違いで作成したコードを使い、以下のリストを読み込ませる。

`````
1,Aさん
2,Bさん
3,Cさん
4,Dさん
5,Eさん
6,Fさん
7,Gさん
`````

### 実行結果

`````
python bad_lottery.py
[['6', 'Fさん'], ['3', 'Cさん'], ['5', 'Eさん'], ['2', 'Bさん'], ['4', 'Dさん']]

python bad_lottery.py
[['6', 'Fさん'], ['4', 'Dさん'], ['2', 'Bさん'], ['5', 'Eさん'], ['1', 'Aさん']]

python bad_lottery.py
[['1', 'Aさん'], ['3', 'Cさん'], ['2', 'Bさん'], ['7', 'Gさん'], ['4', 'Dさん']]
`````

マニュアル記載通り、確かに重複していない。

つまり、
**「配列の要素を重複取得しない」**
のであって、
**「配列の要素内の値まで見てから重複取得しない」**
というのはテメェの勝手な脳内妄想ですよ、と orz

テメェの都合でマニュアル記載を脳内変換するという、
痛い思い込みをしてしまいました・・・。


## 改めて処理を作成

再度仕様を確認。

````
- 一般顧客
    - 抽選元データには1件のみ含まれる, 抽選チャンスは1回だけの顧客

- 優先顧客
    - 抽選元データには2件以上含まれる, 抽選チャンスは含まれる数だけの顧客
    - ただし当選は1回限りとする
````

これを実現するなら、以下の流れになるはず。

````
(1) 抽選対象から当選者をランダム検索する

(2) 当選者は「優先顧客」が重複する場合があるので重複を精査し重複を無くす

(3) (1), (2) を当選者が全て確定するまで回し続ける
````

当選者のランダム検索には、
引き続き random.sample を利用できそう。


## 実装

コマンドライン引数に「当選者数」を入力して実行します。

`````python
import sys
import csv
import random


# メイン処理
def main():
    args = sys.argv
    list_lottery = []
    list_win = []
    win_max = 0
    win = 0

    # 抽選対象者の読み込み
    list_lottery = _csv_read()
    win_max = _check_input(args, len(list_lottery))
    win = win_max

    # 当選者選定
    while len(list_win) < win_max:
        if (len(list_lottery) < win):
            break
        list_win += _get_win(list_lottery, win)
        list_lottery = _del_win(list_lottery, list_win)
        win = win - len(list_win)

    # 当選者出力
    for row in list_win:
        print(row[0] + ',' + row[1])


# 抽選対象データ（csv）の読み込み
def _csv_read():
    list_lottery = []
    csv_file = open("./sample.csv", "r", encoding="ms932",
                    errors="", newline="")
    f = csv.reader(csv_file, delimiter=",", doublequote=True,
                   lineterminator="\r\n", quotechar='"', skipinitialspace=True)
    for row in f:
        list_lottery.append([row[0], row[1]])
    return list_lottery


# 入力値チェック
def _check_input(args, csv_len):
    if len(args) == 1:
        print('INPUT ERROR：第1引数に生成数を指定')
        exit()
    if not args[1].isdigit():
        print('INPUT ERROR：第1引数に数値を指定')
        exit()
    if csv_len < int(args[1]):
        print('INPUT ERROR：抽選対象数よりも当選対象数が大きい')
        exit()

    return int(args[1])


# 当選者選定
def _get_win(list_lottery, win):
    list_win = random.sample(list_lottery, win)
    list_unique = []
    for win in list_win:
        if win not in list_unique:
            list_unique.append(win)
    return list_unique


# 抽選対象者から当選者を除外
def _del_win(list_lottery, list_win):
    arr = []
    for lottery in list_lottery:
        if lottery not in list_win:
            arr.append(lottery)
    return arr


if __name__ == "__main__":
    main()

`````

記事冒頭にある抽選対象データを使って、３回実行してみた結果。

`````
python lottery.py 10
20ebab769cf1,優先顧客Zさん
b7be8af347e7,一般顧客Lさん
709a8a94ccd4,一般顧客Cさん
ab73ce466f67,一般顧客Xさん
a7972229432e,一般顧客Sさん
3c0b73a5902d,一般顧客Bさん
abedb4ac7e6f,一般顧客Jさん
516542e94f03,一般顧客Rさん
4c29ff917494,一般顧客Qさん
60e8691e2512,一般顧客Kさん

python lottery.py 10
f5b37a9f4f94,一般顧客Gさん
8feaf44d7daa,一般顧客Mさん
20ebab769cf1,優先顧客Zさん
60e8691e2512,一般顧客Kさん
ab73ce466f67,一般顧客Xさん
a7972229432e,一般顧客Sさん
b031db7dd5db,一般顧客Pさん
ef1fc02b3eeb,優先顧客Aさん
d53341576571,一般顧客Yさん
b7c55528c0b0,一般顧客Uさん

python lottery.py 10
b7c55528c0b0,一般顧客Uさん
f5b37a9f4f94,一般顧客Gさん
ef1fc02b3eeb,優先顧客Aさん
68c760d5472b,一般顧客Wさん
abedb4ac7e6f,一般顧客Jさん
aa1c91951bbd,一般顧客Hさん
4c29ff917494,一般顧客Qさん
72178ea3f377,一般顧客Dさん
a61821fb1829,一般顧客Vさん
b9be47006237,一般顧客Oさん
`````

重複なし。
かつ、優先顧客は当選率が高くなる、
かつ、当選が重複しないことが確認できました。


## まとめ

◯◯な機能を作りたいなと考えながらマニュアルやWeb記事を見ていると、
内容を自分都合に読み替えて解釈してしまうことがよくあるなと感じています。
（特に自分は、思い込みがホンマ激しいとこがあるから・・・orz）

それを防ぐためにも、技術情報は「見る」だけでなく、
「検証」して理解に落とし込む大切さを改めて痛感した次第。


## おまけ

作成した処理をすこし作り変えて、
「等級対応（１等、２等。。。のように「等」を付けられる抽選）」の処理を書いてみました。
git hub に上げましたので、ご興味あればぜひご覧ください。
https://github.com/masashi-sawai/python-learning/tree/master/tools/lottery/lottery_grade