class StackelbergDuopoly:
"""
Duopole de Stackelberg : equilibre statique ET trajectoire dynamique.
La version statique (solve_static_stackelberg) donne les references
q_stack / q_cournot. La version dynamique (simulate_dynamic) resout un VRAI
controle optimal avec cout d'ajustement -- sans lequel le "dynamique"
s'effondre en statique constant (degenerescence, cf. note cellule suivante).
"""
def __init__(self, T: float, dt: float,
a: float, b: float, # Demande P = a - b*Q
c_L: float, c_F: float, # Couts marginaux
delta: float = 0.1): # Facteur d'actualisation
self.T, self.dt = T, dt
self.a, self.b = a, b
self.c_L, self.c_F = c_L, c_F
self.delta = delta
self.times = np.arange(0, T + dt / 2, dt)
self.q_cournot = (a - c_L) / (3 * b) # Symetrique
def follower_reaction(self, q_L: float) -> float:
"""Meilleure reponse statique du follower."""
q_F = (self.a - self.c_F - self.b * q_L) / (2 * self.b)
return max(0, q_F)
def leader_profit(self, q_L: float, q_F: float) -> float:
"""Profit instantane du leader."""
P = max(0, self.a - self.b * (q_L + q_F))
return (P - self.c_L) * q_L
def follower_profit(self, q_L: float, q_F: float) -> float:
"""Profit instantane du follower."""
P = max(0, self.a - self.b * (q_L + q_F))
return (P - self.c_F) * q_F
def solve_static_stackelberg(self) -> Tuple[float, float, float, float]:
"""Resout l'equilibre de Stackelberg statique (anticipation du follower)."""
def leader_objective(q_L):
q_F = self.follower_reaction(q_L)
return -self.leader_profit(q_L, q_F)
result = minimize_scalar(leader_objective, bounds=(0, self.a / self.b), method='bounded')
q_L_star = result.x
q_F_star = self.follower_reaction(q_L_star)
pi_L = self.leader_profit(q_L_star, q_F_star)
pi_F = self.follower_profit(q_L_star, q_F_star)
return q_L_star, q_F_star, pi_L, pi_F
def _discounted_objective(self, u_L_vec, gamma: float, u_0: float) -> float:
"""Profit actualise moins cout d'ajustement (gamma/2)(du_L/dt)^2."""
disc = np.exp(-self.delta * self.times)
profit = 0.0
adjcost = 0.0
prev = u_0 # condition initiale
for i in range(len(self.times)):
u_F = self.follower_reaction(u_L_vec[i])
profit += disc[i] * self.leader_profit(u_L_vec[i], u_F) * self.dt
du = (u_L_vec[i] - prev) / self.dt
adjcost += disc[i] * 0.5 * gamma * du ** 2 * self.dt
prev = u_L_vec[i]
return profit - adjcost
def simulate_dynamic(self, gamma: float = 6.0, u_0: float = None
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, float, object]:
"""
Resout le Stackelberg DYNAMIQUE avec cout d'ajustement quadratique.
Le leader choisit la trajectoire u_L(t) maximisant le profit actualise
moins le cout d'ajustement (gamma/2)(du_L/dt)^2, en partant de la
condition initiale u_L(0) = u_0 (par defaut q_cournot : le leader, deja
a l'equilibre Cournot, decide a quelle vitesse monter vers le leadership
Stackelberg). Le follower est myope : best-reaction instantanee.
CONTRASTE avec la degenerescence : sans cout d'ajustement (gamma=0) NI
etat, le probleme est separable dans le temps et u_L* = q_stack constant
-- c'est exactement pourquoi une interpolation statique n'est PAS un jeu
dynamique.
Retourne (times, u_L_opt, u_F_opt, objectif, resultat SLSQP).
"""
q_L_stack, _, _, _ = self.solve_static_stackelberg()
if u_0 is None:
u_0 = float(self.q_cournot)
n = len(self.times)
u_init = np.linspace(u_0, q_L_stack, n)
bounds = [(0.0, self.a / self.b)] * n
res = minimize(lambda u: -self._discounted_objective(u, gamma, u_0),
u_init, method="SLSQP", bounds=bounds,
options={"maxiter": 2000, "ftol": 1e-10})
u_L_opt = res.x
u_F_opt = np.array([self.follower_reaction(u) for u in u_L_opt])
obj = self._discounted_objective(u_L_opt, gamma, u_0)
return self.times, u_L_opt, u_F_opt, obj, res
def time_to_fraction(self, u_traj: np.ndarray, frac: float = 0.9) -> float:
"""Temps pour atteindre frac du chemin u_0 -> q_stack."""
q_L_stack, _, _, _ = self.solve_static_stackelberg()
u_0 = u_traj[0]
target = u_0 + frac * (q_L_stack - u_0)
for i, u in enumerate(u_traj):
if u >= target:
return float(self.times[i])
return float(self.T)
# --- Analyse : Stackelberg dynamique avec cout d'ajustement ---
print("Stackelberg Dynamique : cout d'ajustement et ramp-up")
print("=" * 60)
duopoly = StackelbergDuopoly(T=10, dt=0.20, a=100, b=1, c_L=10, c_F=10, delta=0.15)
q_L_stack, q_F_stack, pi_L_stack, pi_F_stack = duopoly.solve_static_stackelberg()
print(f"References statiques : Cournot q={duopoly.q_cournot:.2f}, "
f"Stackelberg q_L={q_L_stack:.2f} (pi_L={pi_L_stack:.1f})")
print(f"Parametres : T={duopoly.T}, dt={duopoly.dt}, delta={duopoly.delta}, gamma=6.0")
print()
# Trois strategies : dynamique-optimal vs myope (Cournot) vs saut aggressif
times, u_opt, u_F_opt, obj_opt, res = duopoly.simulate_dynamic(gamma=6.0)
obj_myope = duopoly._discounted_objective(np.full(len(times), duopoly.q_cournot), 6.0, duopoly.q_cournot)
obj_jump = duopoly._discounted_objective(np.full(len(times), q_L_stack), 6.0, duopoly.q_cournot)
print("Profit actualise net sur [0, T] pour 3 strategies :")
print(f" myope (rester a Cournot) : {obj_myope:8.1f} (cout d'ajustement nul)")
print(f" saut aggressif (-> q_stack) : {obj_jump:8.1f} (cout d'ajustement ecrasant au pas 0)")
print(f" dynamique-optimal (ramp lisse) : {obj_opt:8.1f} <-- bat les deux")
print()
print(f"SLSQP : success={res.success}, nit={res.nit}")
print(f"Trajectoire optimale : u_L* ramp de {u_opt[0]:.2f} -> {u_opt[-1]:.2f} "
f"(std={u_opt.std():.2f}, non-constant)")
t90 = duopoly.time_to_fraction(u_opt, 0.9)
print(f"Temps pour atteindre 90% du ramp : t90={t90:.2f} (sur T={duopoly.T})")