数理最適化でアドベントカレンダーのお菓子を決めよう

去年のアドベントカレンダーの最適化の記事を参考に問題を作成しました。

https://qiita.com/doiken_/items/8cc82b0d80f4f5852193

（記事のデータや条件を少し変えています）

## 問題

16種類、136個のお菓子があります。
このお菓子をアドベントカレンダー用の25個の袋に入れてください。

データはCSVに入っています。

```csvs:advent.csv
商品,小分け数,単位,大きさ
A. ブルボン プチチョコラングドシャ,1,1,90
B. グリコ 冬のくちどけポッキー,2,1,90
C. グリコ 冬のきらめきポッキー,2,1,90
D. 亀田製菓 亀田のうす焼えび,3,1,90
E. キタノ商事 トップオブザポップ バター,1,1,90
F. 江崎グリコ プリッツ(ロースト 塩バター),2,1,90
G. ロッテ トッポ(ザ・ショコラ),2,1,90
H. グリコ 生チーズのチーザ カマンベールチーズ,1,1,90
I. 明治 メルティーキッスプレミアムショコラ,14,2,5
J. 明治 メルティーキッスフルーティー濃いちご,14,2,5
K. 明治 メルティーキッス初摘み濃抹茶,14,2,5
L. 亀田製菓 ハッピーターン,24,2,10
M. ネスレ日本 キットカット,12,2,10
N. 亀田製菓 亀田のまがりせんべい,10,2,15
O. 国産小麦の厚切りバウムクーヘン,10,2,15
P. ミックスゼリー(135g),24,2,5
```

<table border="1" class="dataframe"><thead><tr><th>商品</th><th>小分け数</th><th>単位</th><th>大きさ</th></tr></thead><tbody><tr><td>A. ブルボン プチチョコラングドシャ</td><td>1</td><td>1</td><td>90</td></tr><tr><td>B. グリコ 冬のくちどけポッキー</td><td>2</td><td>1</td><td>90</td></tr><tr><td>C. グリコ 冬のきらめきポッキー</td><td>2</td><td>1</td><td>90</td></tr><tr><td>D. 亀田製菓 亀田のうす焼えび</td><td>3</td><td>1</td><td>90</td></tr><tr><td>E. キタノ商事 トップオブザポップ バター</td><td>1</td><td>1</td><td>90</td></tr><tr><td>F. 江崎グリコ プリッツ(ロースト 塩バター)</td><td>2</td><td>1</td><td>90</td></tr><tr><td>G. ロッテ トッポ(ザ・ショコラ)</td><td>2</td><td>1</td><td>90</td></tr><tr><td>H. グリコ 生チーズのチーザ カマンベールチーズ</td><td>1</td><td>1</td><td>90</td></tr><tr><td>I. 明治 メルティーキッスプレミアムショコラ</td><td>14</td><td>2</td><td>5</td></tr><tr><td>J. 明治 メルティーキッスフルーティー濃いちご</td><td>14</td><td>2</td><td>5</td></tr><tr><td>K. 明治 メルティーキッス初摘み濃抹茶</td><td>14</td><td>2</td><td>5</td></tr><tr><td>L. 亀田製菓 ハッピーターン</td><td>24</td><td>2</td><td>10</td></tr><tr><td>M. ネスレ日本 キットカット</td><td>12</td><td>2</td><td>10</td></tr><tr><td>N. 亀田製菓 亀田のまがりせんべい</td><td>10</td><td>2</td><td>15</td></tr><tr><td>O. 国産小麦の厚切りバウムクーヘン</td><td>10</td><td>2</td><td>15</td></tr><tr><td>P. ミックスゼリー(135g)</td><td>24</td><td>2</td><td>5</td></tr></tbody></table>

### 条件その1

お菓子は大きいもの（列`単位`が1）と小さいもの（列`単位`が2）があります。
小さいものは2人で分けられるように2の倍数の個数にしてください。
小さいものの`小分け数`は偶数になっています。
また、いろいろ食べられるように1種類は1袋に4個までとします。

### 条件その2

袋の大きさは90なので、列`大きさ`の合計が90までしか入りません。
また、`大きさ`の合計は`90 * 25`なので、25個とも90になります。

### 条件その3

前の日と同じお菓子が入っていると残念な気持ちになるので、同じ商品が2日連続しないようにしてください。

## 解答

このような問題は数理最適化で解くことができます。
数理最適化では、数理モデルを作成してソルバーで解を求めます。

詳しくは下記を参考にしてください。

https://qiita.com/SaitoTsutomu/items/bfbf4c185ed7004b5721

まずは、数理モデルを考えます。以降ではお菓子を商品とも呼ぶことにします。

### 数理モデル

数理モデルは、変数、目的関数、制約条件から構成されます。

#### 変数

変数は、2種類考えます。

1つは、あるお菓子をある日に選ぶかどうかを表す変数です。これは0-1変数を使います（`V01`とします）。
もう1つは、何単位買うかを表す変数です。これは整数変数を使います（`_Vi`とします）。

それぞれ`16種類 x 25日分`用意します。

ここで、`_Vi`に`単位`を掛けたものが個数になります。これを`Var`とします。`Var`は数式ですが、便宜上、これも変数とみなしましょう。

#### 目的関数

今回は、目的関数はありません。

#### 制約条件

3つの制約条件は、次のように考えます。

* `V01`を1にすれば`Var`を4個まで可能
* 各日の大きさの和は90
* 同じ商品を2日連続にしない

また、商品は小分け数までしかないので、下記の制約条件も必要です。

* 商品ごとの小計は小分け数

まとめると次のようになります。

| 構成要素 | 内容                                                                                                                |
| :------- | :------------------------------------------------------------------------------------------------------------------ |
| 変数     | `V01`: 入れるかどうか（商品ごと、日ごと）<br>`_Vi`: 単位数（商品ごと、日ごと）<br>`Var`: 個数 （商品ごと、日ごと）  |
| 目的関数 | なし                                                                                                                |
| 制約条件 | `V01`を1にすれば`Var`を4個まで可能<br>各日の大きさの和は90<br>同じ商品を2日連続にしない<br>商品ごとの小計は小分け数 |

## 実装

PolarsとPython−MIPを使って数理モデルを作成して解いてみましょう。

Polarsを使ったPython-MIPの数理モデルの作り方は次を参照してください。

https://qiita.com/SaitoTsutomu/items/16f588265a01feacecbe

```python
import numpy as np
import polars as pl
from mip import Model, xsum

def xmul(s1, s2):
    """内積"""
    return np.array(s1) * np.array(s2)

df = pl.read_csv("advent.csv").join(
    pl.DataFrame(range(1, 26), ["日"]), how="cross"
)
# 数理モデル
m = Model()
V01 = m.add_var_tensor((len(df),), "V01", var_type="B")
_Vi = m.add_var_tensor((len(df),), "_Vi", var_type="I")
# 変数追加
df = df.with_columns(V01=V01, Var=xmul(_Vi, df["単位"]))

for row in df.iter_rows(named=True):
    # V01を1にすればVarを最大個数まで可能
    m += row["Var"] <= 4 * row["V01"]

for _, group in df.group_by("日", maintain_order=True):
    # 各日の大きさの和は90
    m += xsum(xmul(group["Var"], group["大きさ"])) == 90

for _, group in df.group_by("商品", maintain_order=True):
    for i in range(1, 25):
        # 同じ商品を2日連続にしない
        m += xsum(group.filter(pl.col("日").is_between(i, i + 1))["V01"]) <= 1

for _, group in df.group_by("商品", maintain_order=True):
    # 商品ごとの小計は小分け数
    m += xsum(group["Var"]) == group["小分け数"][0]

m.verbose = 0
# ソルバー実行
m.optimize()
print(m.status)  # 結果のステータス
# 結果の列を追加
df = df.with_columns(
    Val=df["Var"].to_numpy().astype(float).round().astype(int)
)
# 結果の表示
print(
    df.filter(df["Val"] > 0)
    .group_by("日")
    .agg(pl.struct("商品", "Val"))
    .sort("日")
)
```

**出力**

<div>OptimizationStatus.OPTIMAL</div>
<small>shape: (25, 2)</small><table border="1" class="dataframe"><thead><tr><th>日</th><th>商品</th></tr><tr><td>i64</td><td>list[struct[2]]</td></tr></thead><tbody><tr><td>1</td><td>[{&quot;I. 明治 メルティーキッスプレミアムショコラ&quot;,4}, {&quot;K. 明治 メルティーキッス初摘み濃抹茶&quot;,2}, {&quot;L. 亀田製菓 ハッピーターン&quot;,4}, {&quot;P. ミックスゼリー(135g)&quot;,4}]</td></tr><tr><td>2</td><td>[{&quot;J. 明治 メルティーキッスフルーティー濃いちご&quot;,4}, {&quot;M. ネスレ日本 キットカット&quot;,4}, {&quot;N. 亀田製菓 亀田のまがりせんべい&quot;,2}]</td></tr><tr><td>3</td><td>[{&quot;I. 明治 メルティーキッスプレミアムショコラ&quot;,4}, {&quot;K. 明治 メルティーキッス初摘み濃抹茶&quot;,2}, {&quot;L. 亀田製菓 ハッピーターン&quot;,4}, {&quot;P. ミックスゼリー(135g)&quot;,4}]</td></tr><tr><td>4</td><td>[{&quot;G. ロッテ トッポ(ザ・ショコラ)&quot;,1}]</td></tr><tr><td>5</td><td>[{&quot;K. 明治 メルティーキッス初摘み濃抹茶&quot;,4}, {&quot;L. 亀田製菓 ハッピーターン&quot;,2}, {&quot;O. 国産小麦の厚切りバウムクーヘン&quot;,2}, {&quot;P. ミックスゼリー(135g)&quot;,4}]</td></tr><tr><td>6</td><td>[{&quot;J. 明治 メルティーキッスフルーティー濃いちご&quot;,4}, {&quot;M. ネスレ日本 キットカット&quot;,4}, {&quot;N. 亀田製菓 亀田のまがりせんべい&quot;,2}]</td></tr><tr><td>7</td><td>[{&quot;K. 明治 メルティーキッス初摘み濃抹茶&quot;,4}, {&quot;L. 亀田製菓 ハッピーターン&quot;,2}, {&quot;O. 国産小麦の厚切りバウムクーヘン&quot;,2}, {&quot;P. ミックスゼリー(135g)&quot;,4}]</td></tr><tr><td>8</td><td>[{&quot;B. グリコ 冬のくちどけポッキー&quot;,1}]</td></tr><tr><td>9</td><td>[{&quot;I. 明治 メルティーキッスプレミアムショコラ&quot;,2}, {&quot;N. 亀田製菓 亀田のまがりせんべい&quot;,2}, {&quot;O. 国産小麦の厚切りバウムクーヘン&quot;,2}, {&quot;P. ミックスゼリー(135g)&quot;,4}]</td></tr><tr><td>10</td><td>[{&quot;D. 亀田製菓 亀田のうす焼えび&quot;,1}]</td></tr><tr><td>11</td><td>[{&quot;B. グリコ 冬のくちどけポッキー&quot;,1}]</td></tr><tr><td>12</td><td>[{&quot;F. 江崎グリコ プリッツ(ロースト 塩バター)&quot;,1}]</td></tr><tr><td>13</td><td>[{&quot;H. グリコ 生チーズのチーザ カマンベールチーズ&quot;,1}]</td></tr><tr><td>14</td><td>[{&quot;D. 亀田製菓 亀田のうす焼えび&quot;,1}]</td></tr><tr><td>15</td><td>[{&quot;I. 明治 メルティーキッスプレミアムショコラ&quot;,4}, {&quot;J. 明治 メルティーキッスフルーティー濃いちご&quot;,4}, {&quot;M. ネスレ日本 キットカット&quot;,2}, {&quot;O. 国産小麦の厚切りバウムクーヘン&quot;,2}]</td></tr><tr><td>16</td><td>[{&quot;G. ロッテ トッポ(ザ・ショコラ)&quot;,1}]</td></tr><tr><td>17</td><td>[{&quot;F. 江崎グリコ プリッツ(ロースト 塩バター)&quot;,1}]</td></tr><tr><td>18</td><td>[{&quot;C. グリコ 冬のきらめきポッキー&quot;,1}]</td></tr><tr><td>19</td><td>[{&quot;L. 亀田製菓 ハッピーターン&quot;,4}, {&quot;M. ネスレ日本 キットカット&quot;,2}, {&quot;O. 国産小麦の厚切りバウムクーヘン&quot;,2}]</td></tr><tr><td>20</td><td>[{&quot;D. 亀田製菓 亀田のうす焼えび&quot;,1}]</td></tr><tr><td>21</td><td>[{&quot;E. キタノ商事 トップオブザポップ バター&quot;,1}]</td></tr><tr><td>22</td><td>[{&quot;A. ブルボン プチチョコラングドシャ&quot;,1}]</td></tr><tr><td>23</td><td>[{&quot;J. 明治 メルティーキッスフルーティー濃いちご&quot;,2}, {&quot;K. 明治 メルティーキッス初摘み濃抹茶&quot;,2}, {&quot;L. 亀田製菓 ハッピーターン&quot;,4}, {&quot;N. 亀田製菓 亀田のまがりせんべい&quot;,2}]</td></tr><tr><td>24</td><td>[{&quot;C. グリコ 冬のきらめきポッキー&quot;,1}]</td></tr><tr><td>25</td><td>[{&quot;L. 亀田製菓 ハッピーターン&quot;,4}, {&quot;N. 亀田製菓 亀田のまがりせんべい&quot;,2}, {&quot;P. ミックスゼリー(135g)&quot;,4}]</td></tr></tbody></table>

全条件を満たした解が得られました。

以上