import random
from collections import Counter
import matplotlib.pyplot as plt
print("Imports OK : random, collections, matplotlib")Imports OK : random, collections, matplotlib
Bandeau preprint : ce notebook s’appuie sur The Matroid Secretary Conjecture is True (Sahil Singla, arXiv 2609.14555, soumis le 13/09/2026 — 72 h avant l’écriture de ces lignes, non relu). Nous ne démontrons RIEN : nous implémentons l’algorithme du papier (explicite) et nous mesurons la garantie élément-par-élément sur des matroïdes jouets. Même discipline d’honnêteté que rl_17 : mesurer, jamais re-prouver.
| Module | Décisions online sous incertitude — secrétaire et matroïdes |
| Niveau | Avancé (suite directe de rl_17 : analyse compétitive) |
| Technologies | Python stdlib pur (random + structures élémentaires) |
| Durée estimée | 35-40 min — instances jouets CPU, exécution complète < 2 min |
Objectifs d’apprentissage — à la fin de ce notebook, vous saurez : - [ ] énoncer le problème du secrétaire (classique puis matroïdal) et sa garantie ; - [ ] manipuler trois familles de matroïdes (uniforme, partition, graphique) via un simple oracle d’indépendance ; - [ ] dérouler l’algorithme de Singla (échantillon Bin(n, 1/2), configuration virtuelle, test du glouton des deux côtés) et comprendre pourquoi il ne connaît que n ; - [ ] mesurer P[acceptation de chaque élément de l’optimum] et la comparer à la borne 1/4 — y compris le prix de l’universalité face au 1/e du secrétaire classique ; - [ ] utiliser une garantie théorique comme test exécutable de votre implémentation.
Prérequis : rl_17 (k-server, work function) — le fil « décisions online » ; aucune mathématique au-delà d’un tri et d’un arbre couvrant.
Navigation : README série — compagnon thématique : rl_17 (k-server WFA)
Le problème des secrétaires (variante de l’arrêt optimal) : n candidats défilent dans un ordre aléatoire, on ne peut pas revenir en arrière, et l’objectif est de maximiser la probabilité d’embaucher le meilleur. La stratégie optimale observe une fraction 1/e des candidats puis embauche le premier qui bat le maximum observé — probabilité de succès 1/e ≈ 36,8 % (Lindley 1961, Dynkin 1963). C’est le k=1 de ce notebook.
La version matroïdale (Babaioff, Immorlica, Kleinberg, 2007) ajoute une contrainte d’indépendance : les éléments acceptés doivent former un ensemble indépendant au sens d’un matroïde — par exemple « au plus k éléments » (matroïde uniforme, = k-secretary), « au plus c_i éléments par classe » (partition, = admis concurrentiels par poste), ou « pas de cycle » (graphique, = arêtes d’une forêt, par exemple construire un réseau online). Les poids sont révélés dans un ordre aléatoire, la décision est immédiate et irréversible.
Conjecture (2007-2026). Il existerait un algorithme constante-compétitif pour tout matroïde. Le preprint cité en tête la clôt : un algorithme online accepte chaque élément de l’optimum offline avec probabilité ≥ 1/4, en ne connaissant à l’avance que n (le nombre d’éléments) et un oracle d’indépendance — il n’a même pas besoin de connaître le matroïde. Par linéarité de l’espérance, E[ALG] ≥ OPT/4.
La garantie élément-par-élément est plus forte qu’un ratio d’espérance : elle se mesure directement, élément par élément, sur des instances jouets. C’est ce que ce notebook fait.
Un matroïde est ici une famille d’ensembles indépendants (héréditaire + propriété d’échange — nous n’en aurons pas besoin explicitement : seul l’oracle compte). Chaque famille s’interroge en une petite fermeture :
def uniform_oracle(n, k):
"""Matroide uniforme de rang k : independant ssi |S| <= k."""
return lambda S: len(S) <= k
def partition_oracle(classes, caps):
"""Matroide de partition : au plus caps[c] elements par classe c."""
def ind(S):
cnt = Counter(classes[e] for e in S)
return all(cnt[c] <= caps[c] for c in cnt)
return ind
def graphic_oracle(n_vertices, edges):
"""Matroide graphique : independant ssi les aretes forment une foret (union-find)."""
def ind(S):
parent = list(range(n_vertices))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for e in S:
ra, rb = find(edges[e][0]), find(edges[e][1])
if ra == rb:
return False
parent[ra] = rb
return True
return ind
# L'instance temoin du notebook : 8 elements, poids fixes
VALEURS = [5.0, 1.0, 4.0, 3.0, 0.5, 2.0, 4.5, 0.2]
CLASSES = [0, 0, 0, 1, 1, 1, 2, 2]
CAPS = {0: 1, 1: 2, 2: 1}
ARETES = [(0, 1), (0, 2), (1, 2), (1, 3), (2, 3), (2, 4), (3, 4), (0, 4)]
print(f"Instance temoin : n=8 elements, valeurs {VALEURS}")
print(f"Uniforme rang 3 : accepter {{0, 2, 6}} ? {uniform_oracle(8, 3)([0, 2, 6])} ; {{0, 1, 2, 6}} ? {uniform_oracle(8, 3)([0, 1, 2, 6])}")
print(f"Partition : {{0, 1}} (deux de classe 0, cap 1) ? {partition_oracle(CLASSES, CAPS)([0, 1])} ; {{0, 3, 4}} ? {partition_oracle(CLASSES, CAPS)([0, 3, 4])}")
print(f"Graphique : {{0, 2, 7}} (triangle 0-1-2 + arete 0-4) ? {graphic_oracle(5, ARETES)([0, 2, 7])} ; {{0, 1, 5}} ? {graphic_oracle(5, ARETES)([0, 1, 5])}")Instance temoin : n=8 elements, valeurs [5.0, 1.0, 4.0, 3.0, 0.5, 2.0, 4.5, 0.2]
Uniforme rang 3 : accepter {0, 2, 6} ? True ; {0, 1, 2, 6} ? False
Partition : {0, 1} (deux de classe 0, cap 1) ? False ; {0, 3, 4} ? True
Graphique : {0, 2, 7} (triangle 0-1-2 + arete 0-4) ? True ; {0, 1, 5} ? True
Sur un matroïde, l’algorithme glouton (trier par poids décroissant, ajouter si l’ensemble reste indépendant) construit la base de poids maximal — c’est LE fait structurant des matroïdes (Rado 1957, Edmonds 1971 ; cité, pas démontré ici). Deux rôles :
greedy(valeurs) nous donne OPT — l’ensemble de référence de la garantie ;def greedy(weights, ind):
"""Base gloutonne par poids decroissants (elements de poids > 0 uniquement)."""
base = []
for _, e in sorted(((w, e) for e, w in enumerate(weights) if w > 0),
key=lambda t: (-t[0], t[1])):
if ind(base + [e]):
base.append(e)
return frozenset(base)
for nom, ind in [("uniforme k=3", uniform_oracle(8, 3)),
("partition", partition_oracle(CLASSES, CAPS)),
("graphique", graphic_oracle(5, ARETES))]:
base = greedy(VALEURS, ind)
print(f"{nom:13s} OPT = {sorted(base)} -> valeur {sum(VALEURS[e] for e in base):.1f}")uniforme k=3 OPT = [0, 2, 6] -> valeur 13.5
partition OPT = [0, 3, 5, 6] -> valeur 14.5
graphique OPT = [0, 2, 3, 6] -> valeur 16.5
La difficulté : aucune distribution de poids n’est connue, aucun seuil n’est calculable d’avance. L’idée du papier (algorithme « RO 2-Sided GoG ») est ordinale — elle ne manipule que des comparaisons :
K ~ Bin(n, 1/2) et mettre de côté les K premiers arrivés (l’ensemble 𝒦) : leurs valeurs, vues mais non acceptables, servent d’échantillon. Pour tout élément : S_e = v_e si e ∈ 𝒦 (côté échantillon), R_e = v_e sinon (côté réel, révélé à l’arrivée) — l’autre côté vaut 0.S_e. À l’arrivée (virtuelle) de e, proposer W ← W[e := R_e] et comparer le glouton des deux côtés : si greedy(W_proposé) ∩ F = greedy(W) ∩ F (mêmes éléments déjà finalisés), alors accepter e ssi e ∈ greedy(W_proposé), et valider la mise à jour ; sinon rejeter e et laisser W inchangé. Dans tous les cas, e rejoint les finalisés F.L’algorithme ne connaît du matroïde que l’oracle (interrogé sur les éléments déjà arrivés), et du monde que n. C’est cette économie d’information qui rend la conjecture difficile.
def singla_run(valeurs, ind, rng):
"""Une execution de l'algorithme (RO 2-Sided GoG + reduction Bin(n,1/2)).
Retourne l'ensemble accepte (les elements mis de cote pour l'echantillon exclus).
"""
n = len(valeurs)
arrivee = list(range(n))
rng.shuffle(arrivee) # ordre d'arrivee (aleatoire)
K = sum(1 for _ in range(n) if rng.random() < 0.5) # K ~ Bin(n, 1/2)
kal = set(arrivee[:K]) # ech. : mis de cote
reel = lambda e: 0.0 if e in kal else valeurs[e]
W = [valeurs[e] if e in kal else 0.0 for e in range(n)] # configuration initiale
# ordre virtuel : K positions marquees AU HASARD -> elements de kal (ordre relatif)
marquees = set(rng.sample(range(n), K))
virtuel = [None] * n
it_kal = iter(arrivee[:K])
it_reste = iter(arrivee[K:])
for p in range(n):
virtuel[p] = next(it_kal) if p in marquees else next(it_reste)
# boucle online
acceptes, finalises = set(), set()
for e in virtuel:
W_propose = W[:]
W_propose[e] = reel(e)
if greedy(W_propose, ind) & finalises == greedy(W, ind) & finalises:
if e in greedy(W_propose, ind):
acceptes.add(e)
W = W_propose
finalises.add(e)
return acceptes - kal
# Exemple unique, trace sommaire
rng_demo = random.Random(1)
alg = singla_run(VALEURS, uniform_oracle(8, 3), rng_demo)
print(f"Une execution (uniforme k=3) : ALG = {sorted(alg)}, valeur {sum(VALEURS[e] for e in alg):.1f} (OPT = 13.5)")Une execution (uniforme k=3) : ALG = [0, 2, 4], valeur 9.5 (OPT = 13.5)
Notre premier prototype avait un bug instructif : les K positions marquées de l’ordre virtuel étaient les K premières (0..K-1) au lieu de K positions aléatoires. L’entrelacement disparaissait — tous les éléments échantillon défilaient d’abord, la configuration W se stabilisait trop tôt, et le banc de mesure donnait, pour le matroïde uniforme de rang 1, une fréquence d’acceptation du meilleur élément de 0,119 — bien sous la garantie 1/4. Le théorème, utilisé comme un test exécutable, avait détecté le bug avant toute relecture de code.
C’est un des services rendus par une garantie propre : P[accept | e ∈ OPT] ≥ 1/4 est falsifiable sur n’importe quel banc — une implémentation correcte ne peut pas le violer (au bruit d’échantillonnage près). La section suivante est exactement ce banc.
def banc_garantie(valeurs, ind, n_essais=4000, seed=7):
"""Pour chaque element de OPT : frequence d'acceptation sur n_essais executions."""
rng = random.Random(seed)
opt = greedy(valeurs, ind)
freq = {e: 0 for e in opt}
valeur_moyenne = 0.0
for _ in range(n_essais):
alg = singla_run(valeurs, ind, rng)
for e in opt:
if e in alg:
freq[e] += 1
valeur_moyenne += sum(valeurs[e] for e in alg)
return opt, {e: freq[e] / n_essais for e in opt}, valeur_moyenne / n_essais
INSTANCES = [
("uniforme k=1", uniform_oracle(8, 1)),
("uniforme k=2", uniform_oracle(8, 2)),
("uniforme k=3", uniform_oracle(8, 3)),
("uniforme k=4", uniform_oracle(8, 4)),
("partition", partition_oracle(CLASSES, CAPS)),
("graphique", graphic_oracle(5, ARETES)),
]
resultats = {}
print(f"{'instance':14s} {'valeur OPT':>10s} {'min P[accept]':>13s} {'E[ALG]/OPT':>10s} freq par element OPT")
for nom, ind in INSTANCES:
opt, freqs, val_moy = banc_garantie(VALEURS, ind)
resultats[nom] = (opt, freqs, val_moy)
v_opt = sum(VALEURS[e] for e in opt)
print(f"{nom:14s} {v_opt:10.1f} {min(freqs.values()):13.3f} {val_moy / v_opt:10.2f} "
+ " ".join(f"{freqs[e]:.2f}" for e in sorted(opt)))instance valeur OPT min P[accept] E[ALG]/OPT freq par element OPT
uniforme k=1 5.0 0.261 0.45 0.26
uniforme k=2 9.5 0.260 0.42 0.27 0.26
uniforme k=3 13.5 0.292 0.42 0.32 0.29 0.30
uniforme k=4 16.5 0.356 0.45 0.38 0.37 0.37 0.36
partition 14.5 0.319 0.45 0.32 0.44 0.45 0.36
graphique 16.5 0.353 0.45 0.39 0.35 0.39 0.37
fig, ax = plt.subplots(figsize=(9.5, 4.2))
noms = [nom for nom, _ in INSTANCES]
lows = [min(resultats[n][1].values()) for n in noms]
x = range(len(noms))
ax.bar(x, lows, 0.55, color="#4C72B0", label="min P[accept | e dans OPT] (mesure, 4000 essais)")
ax.axhline(0.25, color="crimson", ls="--", lw=1.6, label="garantie du theoreme : 1/4")
ax.axhline(1 / 2.71828, color="dimgray", ls=":", lw=1.2, label="1/e = 0.368 (secretaires classiques, optimal k=1)")
ax.set_xticks(list(x))
ax.set_xticklabels(noms, fontsize=9)
ax.set_ylim(0, 0.55)
ax.set_ylabel("probabilite d'acceptation")
ax.set_title("Garantie element-par-element de l'algorithme de Singla : mesure vs borne 1/4")
ax.legend(fontsize=8, loc="upper left")
plt.tight_layout()
plt.show()
Lecture : la borne 1/4 (ligne rouge) n’est jamais franchie ; les points les plus bas sont les matroïdes uniformes de rang 1 et 2 (0,261 et 0,260) — les cas les plus contraints, au plus près de la borne : sur ces instances, la garantie est serrée. Deux régularités lisibles :
La garantie élément-par-élément implique (par linéarité de l’espérance) E[ALG] ≥ OPT/4. Les ratios mesurés vivent entre 0,42 et 0,45 (moyenne 0,439) : environ 1,75 × la garantie. Le même écart « borne pire-cas vs conduite quotidienne » que pour WFA en rl_17 — deux familles d’algorithmes online, deux fois le même motif : la garantie est une ceinture de sécurité, pas une prédiction.
print(f"{'instance':14s} {'E[ALG]/OPT':>10s} {'garantie':>9s}")
for nom, _ in INSTANCES:
_, _, val_moy = resultats[nom]
v_opt = sum(VALEURS[e] for e in resultats[nom][0])
print(f"{nom:14s} {val_moy / v_opt:10.2f} {0.25:>9.2f}")
print("\nMoyenne des 6 ratios :", round(sum(resultats[n][2] / sum(VALEURS[e] for e in resultats[n][0]) for n, _ in INSTANCES) / 6, 3))instance E[ALG]/OPT garantie
uniforme k=1 0.45 0.25
uniforme k=2 0.42 0.25
uniforme k=3 0.42 0.25
uniforme k=4 0.45 0.25
partition 0.45 0.25
graphique 0.45 0.25
Moyenne des 6 ratios : 0.439
Sur le matroïde uniforme de rang 1 (= le problème des secrétaires original), l’algorithme dédié est la stratégie du seuil : observer les n/e premiers candidats, puis accepter le premier qui bat le maximum observé. En petit n, la probabilité exacte de recruter le meilleur vaut (k/n) Σ_{i>k} 1/(i-1) avec k = ⌈n/e⌉ observés — pour n = 8, k = 3 : ≈ 0,410 (mesuré ci-dessous : 0,400). La constante 1/e ≈ 0,368 est la limite asymptotique quand n → ∞ (et la stratégie est optimale dans les deux régimes) — un rappel utile : les constantes élégantes des théorèmes sont souvent des limites, pas des valeurs de petit banc.
L’algorithme de Singla, lui, est universel : le même code, aveugle au matroïde (il n’a que l’oracle), obtient 0,26 sur ce même cas. Le prix de l’universalité sur cette instance : 0,41 → 0,26 (et à la limite : 1/e → 1/4) quand on abandonne la connaissance de la structure pour ne garder qu’un oracle. Cette transition est exactement le contenu de la conjecture : un algorithme unique qui tient sur tout matroïde.
def secretaires_classique(valeurs, rng):
"""Strategie du seuil 1/e : observer n/e candidats, puis prendre le premier qui bat
le max observe. Retourne l'element choisi (ou None)."""
n = len(valeurs)
n_obs = max(1, round(n / 2.71828))
ordre = list(range(n))
rng.shuffle(ordre)
seuil = max(valeurs[e] for e in ordre[:n_obs])
for e in ordre[n_obs:]:
if valeurs[e] > seuil:
return e
return None
rng_cmp = random.Random(11)
T = 4000
succes_classique = 0
for _ in range(T):
if secretaires_classique(VALEURS, rng_cmp) == 0: # 0 = l'element de valeur 5.0
succes_classique += 1
print(f"Secretaire classique (seuil n/e), rang 1, n=8 : P[recruter le meilleur] = {succes_classique / T:.3f}"
f" [formule exacte n=8 : 0.410 ; limite asymptotique 1/e = {1 / 2.71828:.3f}]")
print(f"Algorithme de Singla, meme instance : P[accepter le meilleur] = {resultats['uniforme k=1'][1][0]:.3f}"
f" [garantie 1/4 = 0.250]")Secretaire classique (seuil n/e), rang 1, n=8 : P[recruter le meilleur] = 0.400 [formule exacte n=8 : 0.410 ; limite asymptotique 1/e = 0.368]
Algorithme de Singla, meme instance : P[accepter le meilleur] = 0.261 [garantie 1/4 = 0.250]
| Affirmation | Statut | Preuve dans ce notebook |
|---|---|---|
| P[accept | e ∈ OPT] ≥ 1/4 sur les instances testées | mesuré | §5 : min 0,26 sur 6 instances × 4 000 essais |
| la borne est serrée sur le rang 1 | mesuré | §5 : 0,26 vs 0,25 sur uniforme k=1 |
| la garantie sert de test d’implémentation | vécu | §5 : notre bug donnait 0,119 — détecté par le banc |
| E[ALG]/OPT ≈ 0,44 (0,42-0,45) | mesuré | §6 |
| 1/e optimal pour le secrétaire classique | cité | Lindley 1961, Dynkin 1963 |
| ≥ 1/4 sur tout matroïde, ne connaissant que n + oracle | cité, non prouvé | preprint arXiv 2609.14555 (13/09/2026, non relu) |
| variante ≈ 3,16-compétitive | annoncé, non vérifié | le papier lui-même la qualifie d’unverified |
Limites du banc : n = 8 éléments (l’échantillon Bin(n,1/2) est petit), 3 familles de matroïdes sur l’infinité existante, 4 000 essais (bruit ± 0,008). Le pire cas théorique — le matroïde qui force exactement 1/4 — est construit par la preuve, pas par notre échantillonnage ; rien ici ne distingue « borne juste » de « borne conservatrice », seulement « borne jamais violée ».
Trois exercices, du plus guidé au plus ouvert. Rappel C.1 : les stubs ne lèvent jamais d’erreur — le notebook s’exécute de bout en bout non complété.
Dans le partition de §2, l’élément de la classe 0 (quota 1) plafonne à ≈ 0,32. Faites varier le quota de cette classe de 1 à 3 (les autres inchangés) et mesurez le minimum des fréquences à chaque fois. Question : le goulot suit-il le quota (l’élément contraint décolle-t-il vers les 0,44 des éléments libres), ou reste-t-il collé à la borne ?
Indices : (1) réutilisez banc_garantie avec un partition_oracle à quotas modifiés ; (2) un essai par quota suffit (4 000 exécutions) ; (3) représentez min P[accept] vs quota.
La mesure du §5 s’arrête à 0,26. Construisez une instance (valeurs et matroïde de votre choix parmi les trois familles, n ≤ 10) dont le minimum mesuré soit le plus bas possible sans passer sous 0,25. Deux pistes à explorer : des valeurs très serrées (l’ordre glouton devient instable) et des contraintes fortes sur les meilleurs éléments. Que suggère votre meilleur « presque 1/4 » sur la question « la borne est-elle atteinte » ?
Indices : (1) partez de l’instance témoin et resserrez les écarts ; (2) banc_garantie retourne les fréquences par élément — regardez toujours le min ; (3) attention au bruit : 4 000 essais = ±0,008, ne concluez pas sur 0,249.
Généralisez la stratégie du seuil au matroïde uniforme de rang 2 : observer n/e candidats, puis accepter les candidats qui battent le max observé jusqu’à avoir 2 éléments. Mesurez P[les DEUX éléments de l’OPT sont acceptés] et comparez au carré des fréquences de Singla sur k=2 (§5 : ≈ 0,26² ≈ 0,07 pour l’intersection — à recalculer proprement depuis les fréquences individuelles). La stratégie dédiée bat-elle l’universelle sur l’événement « tout l’OPT recruté » ?
Indices : (1) le classique s’arrête au premier succès — ici il faut une liste d’acceptés ; (2) que faire si le seuil ne laisse passer que 1 candidat après n/e ? Comparez deux politiques de rattrapage (prendre le dernier, prendre les 2 derniers) ; (3) mesurez l’intersection exacte avec un compteur sur les exécutions de Singla.
Exercice a completer
Pour aller plus loin : rl_17 (k-server, l’autre conjecture tombée à 24 h d’écart) ; Complexity/Complexity-04b (le binôme synthétique : les deux conjectures croisées, baseline 1/e et WFA, côté série Complexity) ; le k-secretary de Kleinberg (2005) ; les algorithmes par classe de matroïdes (graphique, laminar) qui dominaient avant le résultat général ; la version prophet inequality (le même papier annonce 8-compétitif single-sample).
Notebook RL 18 — deuxième distillation d’un preprint de 72 h : la garantie d’un théorème comme banc de mesure et test d’implémentation, sans en démontrer la preuve.