You've already forked Commercialization.tapadn
1440 lines
58 KiB
Python
1440 lines
58 KiB
Python
|
|
import argparse
|
|||
|
|
import csv
|
|||
|
|
import json
|
|||
|
|
import random
|
|||
|
|
from collections import Counter, defaultdict
|
|||
|
|
from dataclasses import dataclass
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Dict, List, Tuple
|
|||
|
|
|
|||
|
|
import matplotlib
|
|||
|
|
import matplotlib.pyplot as plt
|
|||
|
|
from matplotlib import font_manager
|
|||
|
|
|
|||
|
|
|
|||
|
|
def configure_fonts() -> None:
|
|||
|
|
candidates = ["Microsoft YaHei", "SimHei", "PingFang SC", "Noto Sans CJK SC", "Noto Sans CJK TC"]
|
|||
|
|
all_fonts = {f.name for f in font_manager.fontManager.ttflist}
|
|||
|
|
for font in candidates:
|
|||
|
|
if font in all_fonts:
|
|||
|
|
matplotlib.rcParams["font.sans-serif"] = [font]
|
|||
|
|
matplotlib.rcParams["axes.unicode_minus"] = False
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
|
|||
|
|
configure_fonts()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def safe_ratio(numerator: float, denominator: float) -> float:
|
|||
|
|
return numerator / denominator if denominator > 0 else 0.0
|
|||
|
|
|
|||
|
|
|
|||
|
|
def clamp01(value: float) -> float:
|
|||
|
|
return max(0.0, min(1.0, value))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def lerp(a: float, b: float, t: float) -> float:
|
|||
|
|
return a + (b - a) * clamp01(t)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def clamp_float(value: float, min_v: float, max_v: float) -> float:
|
|||
|
|
return max(min_v, min(max_v, value))
|
|||
|
|
|
|||
|
|
|
|||
|
|
NETWORK_LABELS = ("wifi", "4g", "3g", "2g")
|
|||
|
|
NETWORK_COUNT = len(NETWORK_LABELS)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def percentile_95(values: List[float]) -> float:
|
|||
|
|
if not values:
|
|||
|
|
return 0.0
|
|||
|
|
sorted_values = sorted(values)
|
|||
|
|
idx = max(0, int(len(sorted_values) * 0.95) - 1)
|
|||
|
|
return float(sorted_values[idx])
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class Policy:
|
|||
|
|
ad_type: int
|
|||
|
|
scene: str
|
|||
|
|
base_probability: float
|
|||
|
|
preload_threshold: float
|
|||
|
|
cooldown_seconds: int
|
|||
|
|
min_samples_for_confidence: int
|
|||
|
|
decay_half_life_hours: float
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class SceneProfile:
|
|||
|
|
ad_type: int
|
|||
|
|
scene: str
|
|||
|
|
true_request_probability: float
|
|||
|
|
fill_success_probability: float
|
|||
|
|
mean_load_ms: float
|
|||
|
|
per_session_enter_probability: float
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class NetworkCondition:
|
|||
|
|
name: str
|
|||
|
|
request_factor: float
|
|||
|
|
fill_success_factor: float
|
|||
|
|
load_ms_factor: float
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class RobotProfile:
|
|||
|
|
robot_id: str
|
|||
|
|
archetype: str
|
|||
|
|
enter_multipliers: Tuple[float, float, float]
|
|||
|
|
request_multipliers: Tuple[float, float, float]
|
|||
|
|
fill_affinity: float
|
|||
|
|
latency_affinity: float
|
|||
|
|
retention_affinity: float
|
|||
|
|
network_weights: Tuple[float, float, float, float]
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class SceneState:
|
|||
|
|
ad_type: int
|
|||
|
|
scene: str
|
|||
|
|
policy: Policy
|
|||
|
|
enter_count: int = 0
|
|||
|
|
play_request_count: int = 0
|
|||
|
|
preload_request_count: int = 0
|
|||
|
|
preload_success_count: int = 0
|
|||
|
|
preload_failure_count: int = 0
|
|||
|
|
show_request_count: int = 0
|
|||
|
|
show_start_count: int = 0
|
|||
|
|
show_failure_count: int = 0
|
|||
|
|
last_updated_ts: float = 0.0
|
|||
|
|
last_preload_ts: float = 0.0
|
|||
|
|
preload_expire_ts: float = -1.0
|
|||
|
|
preload_ready: bool = False
|
|||
|
|
preload_origin_network: int = -1
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class RunResult:
|
|||
|
|
mode: str
|
|||
|
|
retention: float
|
|||
|
|
threshold: float
|
|||
|
|
cooldown_seconds: int
|
|||
|
|
total_users: int
|
|||
|
|
total_sessions: int
|
|||
|
|
total_scene_entries: int
|
|||
|
|
total_show_requests: int
|
|||
|
|
total_preload_requests: int
|
|||
|
|
total_preload_success: int
|
|||
|
|
total_preload_failure: int
|
|||
|
|
total_show_success: int
|
|||
|
|
total_show_fail: int
|
|||
|
|
total_preload_waste: int
|
|||
|
|
immediate_play_count: int
|
|||
|
|
avg_wait_ms: float
|
|||
|
|
p95_wait_ms: float
|
|||
|
|
wasted_ratio: float
|
|||
|
|
preload_success_rate: float
|
|||
|
|
show_success_rate: float
|
|||
|
|
network_show_requests: Tuple[int, int, int, int]
|
|||
|
|
network_immediate_show: Tuple[int, int, int, int]
|
|||
|
|
network_preload_requests: Tuple[int, int, int, int]
|
|||
|
|
network_preload_success: Tuple[int, int, int, int]
|
|||
|
|
network_preload_waste: Tuple[int, int, int, int]
|
|||
|
|
network_avg_wait_ms: Tuple[float, float, float, float]
|
|||
|
|
network_p95_wait_ms: Tuple[float, float, float, float]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def default_policy_by_ad_type() -> Dict[Tuple[int, str], Policy]:
|
|||
|
|
return {
|
|||
|
|
(0, "__default__"): Policy(0, "__default__", 0.08, 0.75, 120, 8, 72.0), # Reward
|
|||
|
|
(1, "__default__"): Policy(1, "__default__", 0.08, 0.75, 120, 8, 72.0), # Splash
|
|||
|
|
(2, "__default__"): Policy(2, "__default__", 0.08, 0.75, 120, 8, 72.0), # Interaction
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def parse_policy_config(path: Path) -> Dict[Tuple[int, str], Policy]:
|
|||
|
|
if not path.exists():
|
|||
|
|
return default_policy_by_ad_type()
|
|||
|
|
|
|||
|
|
with path.open("r", encoding="utf-8") as f:
|
|||
|
|
payload = json.loads(f.read())
|
|||
|
|
|
|||
|
|
result: Dict[Tuple[int, str], Policy] = {}
|
|||
|
|
scene_items = payload.get("ScenePolicies", []) or []
|
|||
|
|
for item in scene_items:
|
|||
|
|
ad_type = int(item.get("AdType", -1))
|
|||
|
|
scene = str(item.get("Scene", "")).strip()
|
|||
|
|
if ad_type < 0 or not scene:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
result[(ad_type, scene)] = Policy(
|
|||
|
|
ad_type=ad_type,
|
|||
|
|
scene=scene,
|
|||
|
|
base_probability=float(item.get("BaseProbability", 0.08)),
|
|||
|
|
preload_threshold=float(item.get("PreloadThreshold", 0.75)),
|
|||
|
|
cooldown_seconds=int(item.get("CooldownSeconds", 120)),
|
|||
|
|
min_samples_for_confidence=max(1, int(item.get("MinSamplesForConfidence", 8))),
|
|||
|
|
decay_half_life_hours=max(1.0, float(item.get("DecayHalfLifeHours", 72.0))),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
default = payload.get("GlobalDefault", {}) if isinstance(payload, dict) else {}
|
|||
|
|
for ad_type in (0, 1, 2):
|
|||
|
|
result[(ad_type, "__default__")] = Policy(
|
|||
|
|
ad_type=ad_type,
|
|||
|
|
scene="__default__",
|
|||
|
|
base_probability=float(default.get("BaseProbability", 0.08)),
|
|||
|
|
preload_threshold=float(default.get("PreloadThreshold", 0.75)),
|
|||
|
|
cooldown_seconds=int(default.get("CooldownSeconds", 120)),
|
|||
|
|
min_samples_for_confidence=max(1, int(default.get("MinSamplesForConfidence", 8))),
|
|||
|
|
decay_half_life_hours=max(1.0, float(default.get("DecayHalfLifeHours", 72.0))),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
|
|||
|
|
def estimate_probability(state: SceneState, now: float, policy: Policy) -> float:
|
|||
|
|
if state.enter_count <= 0:
|
|||
|
|
return policy.base_probability
|
|||
|
|
|
|||
|
|
observed = safe_ratio(state.play_request_count, state.enter_count)
|
|||
|
|
trust = min(1.0, state.enter_count / max(1.0, policy.min_samples_for_confidence))
|
|||
|
|
age_hours = max(0.0, (now - state.last_updated_ts) / 3600.0)
|
|||
|
|
decay = 0.5 ** (age_hours / max(1e-6, policy.decay_half_life_hours))
|
|||
|
|
trust *= decay
|
|||
|
|
return lerp(policy.base_probability, observed, trust)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def should_preload(state: SceneState, now: float, threshold: float, cooldown_seconds: int) -> bool:
|
|||
|
|
policy = state.policy
|
|||
|
|
if now - state.last_preload_ts < cooldown_seconds:
|
|||
|
|
return False
|
|||
|
|
if state.preload_ready and state.preload_expire_ts >= now:
|
|||
|
|
return False
|
|||
|
|
return estimate_probability(state, now, policy) >= threshold
|
|||
|
|
|
|||
|
|
|
|||
|
|
def apply_waste_decay(state: SceneState, now: float) -> Tuple[int, int]:
|
|||
|
|
if state.preload_ready and state.preload_expire_ts > 0 and now >= state.preload_expire_ts:
|
|||
|
|
state.preload_ready = False
|
|||
|
|
state.preload_expire_ts = -1
|
|||
|
|
return 1, max(0, min(NETWORK_COUNT - 1, state.preload_origin_network))
|
|||
|
|
return 0, -1
|
|||
|
|
|
|||
|
|
|
|||
|
|
def policy_for_scene(policy_map: Dict[Tuple[int, str], Policy], ad_type: int, scene: str) -> Policy:
|
|||
|
|
return policy_map.get((ad_type, scene)) or policy_map.get((ad_type, "__default__")) or next(
|
|||
|
|
iter(default_policy_by_ad_type().values())
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def pick_network(
|
|||
|
|
conditions: List[NetworkCondition],
|
|||
|
|
weights: Tuple[float, float, float, float],
|
|||
|
|
rng: random.Random,
|
|||
|
|
) -> Tuple[int, NetworkCondition]:
|
|||
|
|
roll = rng.random()
|
|||
|
|
cum = 0.0
|
|||
|
|
for idx, w in enumerate(weights):
|
|||
|
|
cum += max(0.0, w)
|
|||
|
|
if roll <= cum:
|
|||
|
|
i = min(idx, len(conditions) - 1)
|
|||
|
|
return i, conditions[i]
|
|||
|
|
return len(conditions) - 1, conditions[-1]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def normalize_weights(values: Tuple[float, float, float, float], epsilon: float = 1e-6) -> Tuple[float, float, float, float]:
|
|||
|
|
total = sum(max(0.0, v) for v in values)
|
|||
|
|
if total <= epsilon:
|
|||
|
|
return (0.25, 0.25, 0.25, 0.25)
|
|||
|
|
return tuple(v / total for v in values)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def generate_robot_profiles(users: int, seed: int) -> List[RobotProfile]:
|
|||
|
|
rng = random.Random(seed)
|
|||
|
|
|
|||
|
|
# 4 类广告网络环境偏好:Wi-Fi, 4G, 3G, 2G
|
|||
|
|
archetypes = [
|
|||
|
|
{
|
|||
|
|
"name": "reward_heavy",
|
|||
|
|
"weight": 0.25,
|
|||
|
|
"enter": (1.45, 0.82, 0.80),
|
|||
|
|
"request": (1.55, 0.76, 0.62),
|
|||
|
|
"fill_aff": 1.04,
|
|||
|
|
"latency_aff": 0.88,
|
|||
|
|
"retention_aff": 1.18,
|
|||
|
|
"network": (0.60, 0.28, 0.10, 0.02),
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"name": "interstitial_focus",
|
|||
|
|
"weight": 0.22,
|
|||
|
|
"enter": (0.72, 1.48, 0.83),
|
|||
|
|
"request": (0.78, 1.57, 0.70),
|
|||
|
|
"fill_aff": 0.96,
|
|||
|
|
"latency_aff": 1.02,
|
|||
|
|
"retention_aff": 1.05,
|
|||
|
|
"network": (0.32, 0.42, 0.20, 0.06),
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"name": "splash_driven",
|
|||
|
|
"weight": 0.22,
|
|||
|
|
"enter": (0.88, 0.92, 1.52),
|
|||
|
|
"request": (1.02, 1.03, 1.40),
|
|||
|
|
"fill_aff": 0.91,
|
|||
|
|
"latency_aff": 1.01,
|
|||
|
|
"retention_aff": 0.96,
|
|||
|
|
"network": (0.75, 0.18, 0.05, 0.02),
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"name": "balanced",
|
|||
|
|
"weight": 0.20,
|
|||
|
|
"enter": (1.05, 1.12, 1.10),
|
|||
|
|
"request": (1.05, 1.00, 1.00),
|
|||
|
|
"fill_aff": 1.00,
|
|||
|
|
"latency_aff": 1.00,
|
|||
|
|
"retention_aff": 1.00,
|
|||
|
|
"network": (0.40, 0.32, 0.22, 0.06),
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"name": "churn_sensitive",
|
|||
|
|
"weight": 0.11,
|
|||
|
|
"enter": (0.55, 0.64, 0.45),
|
|||
|
|
"request": (0.60, 0.55, 0.46),
|
|||
|
|
"fill_aff": 0.72,
|
|||
|
|
"latency_aff": 1.18,
|
|||
|
|
"retention_aff": 0.58,
|
|||
|
|
"network": (0.24, 0.26, 0.30, 0.20),
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"name": "network_bound",
|
|||
|
|
"weight": 0.10,
|
|||
|
|
"enter": (0.85, 0.85, 0.80),
|
|||
|
|
"request": (0.88, 0.78, 0.72),
|
|||
|
|
"fill_aff": 0.68,
|
|||
|
|
"latency_aff": 1.22,
|
|||
|
|
"retention_aff": 0.82,
|
|||
|
|
"network": (0.12, 0.18, 0.30, 0.40),
|
|||
|
|
},
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
total_weight = sum(a["weight"] for a in archetypes)
|
|||
|
|
running = []
|
|||
|
|
cur = 0.0
|
|||
|
|
for a in archetypes:
|
|||
|
|
cur += a["weight"] / total_weight
|
|||
|
|
running.append((cur, a))
|
|||
|
|
|
|||
|
|
robots = []
|
|||
|
|
for idx in range(users):
|
|||
|
|
roll = rng.random()
|
|||
|
|
picked = archetypes[-1]
|
|||
|
|
for threshold, arche in running:
|
|||
|
|
if roll <= threshold:
|
|||
|
|
picked = arche
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
def jitter(v: float, span: float) -> float:
|
|||
|
|
return clamp01(v + rng.uniform(-span, span))
|
|||
|
|
|
|||
|
|
enters = tuple(clamp_float(jitter(base, 0.24), 0.2, 1.9) for base in picked["enter"])
|
|||
|
|
requests = tuple(clamp_float(jitter(base, 0.22), 0.15, 1.9) for base in picked["request"])
|
|||
|
|
|
|||
|
|
nw = [
|
|||
|
|
max(0.01, jitter(weight, 0.15))
|
|||
|
|
for weight in picked["network"]
|
|||
|
|
]
|
|||
|
|
robots.append(
|
|||
|
|
RobotProfile(
|
|||
|
|
robot_id=f"bot_{idx:05d}",
|
|||
|
|
archetype=picked["name"],
|
|||
|
|
enter_multipliers=enters,
|
|||
|
|
request_multipliers=requests,
|
|||
|
|
fill_affinity=clamp_float(jitter(picked["fill_aff"], 0.16), 0.45, 1.55),
|
|||
|
|
latency_affinity=clamp_float(jitter(picked["latency_aff"], 0.2), 0.6, 1.8),
|
|||
|
|
retention_affinity=clamp_float(jitter(picked["retention_aff"], 0.12), 0.35, 1.45),
|
|||
|
|
network_weights=normalize_weights(tuple(nw)),
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return robots
|
|||
|
|
|
|||
|
|
|
|||
|
|
def simulate_once(
|
|||
|
|
profiles: List[SceneProfile],
|
|||
|
|
policy_map: Dict[Tuple[int, str], Policy],
|
|||
|
|
robots: List[RobotProfile],
|
|||
|
|
threshold: float,
|
|||
|
|
cooldown_seconds: int,
|
|||
|
|
retention_next_day: float,
|
|||
|
|
seed: int,
|
|||
|
|
smart_enabled: bool,
|
|||
|
|
mode: str,
|
|||
|
|
) -> RunResult:
|
|||
|
|
behavior_rng = random.Random(seed)
|
|||
|
|
outcome_rng = random.Random(seed + 998244353)
|
|||
|
|
states: Dict[str, SceneState] = {}
|
|||
|
|
|
|||
|
|
network_conditions = [
|
|||
|
|
NetworkCondition("wifi", request_factor=1.0, fill_success_factor=1.0, load_ms_factor=0.85),
|
|||
|
|
NetworkCondition("4g", request_factor=0.96, fill_success_factor=0.98, load_ms_factor=1.08),
|
|||
|
|
NetworkCondition("3g", request_factor=0.90, fill_success_factor=0.86, load_ms_factor=1.45),
|
|||
|
|
NetworkCondition("2g", request_factor=0.78, fill_success_factor=0.73, load_ms_factor=2.00),
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
def state_of(profile: SceneProfile, profile_index: int) -> SceneState:
|
|||
|
|
key = f"{profile.ad_type}|{profile.scene}"
|
|||
|
|
if key not in states:
|
|||
|
|
states[key] = SceneState(
|
|||
|
|
ad_type=profile.ad_type,
|
|||
|
|
scene=profile.scene,
|
|||
|
|
policy=policy_for_scene(policy_map, profile.ad_type, profile.scene),
|
|||
|
|
)
|
|||
|
|
return states[key]
|
|||
|
|
|
|||
|
|
total_wait_times: List[float] = []
|
|||
|
|
network_wait_times: List[List[float]] = [[] for _ in range(NETWORK_COUNT)]
|
|||
|
|
total_preload_waste = 0
|
|||
|
|
total_scene_entries = 0
|
|||
|
|
total_show_requests = 0
|
|||
|
|
total_show_success = 0
|
|||
|
|
total_show_fail = 0
|
|||
|
|
total_preload_requests = 0
|
|||
|
|
total_preload_success = 0
|
|||
|
|
total_preload_failure = 0
|
|||
|
|
total_sessions = 0
|
|||
|
|
immediate_play_count = 0
|
|||
|
|
network_show_requests = [0, 0, 0, 0]
|
|||
|
|
network_immediate_show = [0, 0, 0, 0]
|
|||
|
|
network_preload_requests = [0, 0, 0, 0]
|
|||
|
|
network_preload_success = [0, 0, 0, 0]
|
|||
|
|
network_preload_waste = [0, 0, 0, 0]
|
|||
|
|
now = 0.0
|
|||
|
|
|
|||
|
|
for robot in robots:
|
|||
|
|
sessions = 1
|
|||
|
|
retention_prob = clamp01(retention_next_day * robot.retention_affinity)
|
|||
|
|
while behavior_rng.random() < retention_prob:
|
|||
|
|
sessions += 1
|
|||
|
|
total_sessions += sessions
|
|||
|
|
|
|||
|
|
for session_index in range(sessions):
|
|||
|
|
now += 0.3 + behavior_rng.random() * 0.7
|
|||
|
|
network_idx, network = pick_network(network_conditions, robot.network_weights, behavior_rng)
|
|||
|
|
|
|||
|
|
for idx, profile in enumerate(profiles):
|
|||
|
|
state = state_of(profile, idx)
|
|||
|
|
now += behavior_rng.uniform(0.3, 2.0)
|
|||
|
|
total_scene_entries += 1
|
|||
|
|
state.enter_count += 1
|
|||
|
|
state.last_updated_ts = now
|
|||
|
|
|
|||
|
|
waste, waste_network = apply_waste_decay(state, now)
|
|||
|
|
total_preload_waste += waste
|
|||
|
|
if waste > 0 and 0 <= waste_network < NETWORK_COUNT:
|
|||
|
|
network_preload_waste[waste_network] += waste
|
|||
|
|
|
|||
|
|
enter_prob = clamp01(
|
|||
|
|
profile.per_session_enter_probability
|
|||
|
|
* robot.enter_multipliers[idx]
|
|||
|
|
* network.request_factor
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if behavior_rng.random() > enter_prob:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if smart_enabled and should_preload(state, now, threshold, cooldown_seconds):
|
|||
|
|
total_preload_requests += 1
|
|||
|
|
state.preload_request_count += 1
|
|||
|
|
state.last_preload_ts = now
|
|||
|
|
state.last_updated_ts = now
|
|||
|
|
network_preload_requests[network_idx] += 1
|
|||
|
|
|
|||
|
|
fill_success = clamp01(profile.fill_success_probability * robot.fill_affinity * network.fill_success_factor)
|
|||
|
|
if outcome_rng.random() < fill_success:
|
|||
|
|
state.preload_success_count += 1
|
|||
|
|
total_preload_success += 1
|
|||
|
|
network_preload_success[network_idx] += 1
|
|||
|
|
state.preload_ready = True
|
|||
|
|
state.preload_expire_ts = now + cooldown_seconds
|
|||
|
|
state.preload_origin_network = network_idx
|
|||
|
|
else:
|
|||
|
|
state.preload_failure_count += 1
|
|||
|
|
total_preload_failure += 1
|
|||
|
|
|
|||
|
|
request_prob = clamp01(profile.true_request_probability * robot.request_multipliers[idx])
|
|||
|
|
if behavior_rng.random() < request_prob:
|
|||
|
|
state.play_request_count += 1
|
|||
|
|
state.show_request_count += 1
|
|||
|
|
total_show_requests += 1
|
|||
|
|
network_show_requests[network_idx] += 1
|
|||
|
|
fill_success = clamp01(profile.fill_success_probability * robot.fill_affinity * network.fill_success_factor)
|
|||
|
|
|
|||
|
|
if state.preload_ready and state.preload_expire_ts >= now:
|
|||
|
|
state.preload_ready = False
|
|||
|
|
state.show_start_count += 1
|
|||
|
|
total_show_success += 1
|
|||
|
|
immediate_play_count += 1
|
|||
|
|
network_immediate_show[network_idx] += 1
|
|||
|
|
total_wait_times.append(0.0)
|
|||
|
|
network_wait_times[network_idx].append(0.0)
|
|||
|
|
state.last_updated_ts = now
|
|||
|
|
else:
|
|||
|
|
load_ms = max(
|
|||
|
|
250.0,
|
|||
|
|
outcome_rng.gauss(
|
|||
|
|
profile.mean_load_ms * robot.latency_affinity * network.load_ms_factor,
|
|||
|
|
220.0,
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
if outcome_rng.random() < fill_success:
|
|||
|
|
state.show_start_count += 1
|
|||
|
|
total_show_success += 1
|
|||
|
|
else:
|
|||
|
|
state.show_failure_count += 1
|
|||
|
|
total_show_fail += 1
|
|||
|
|
total_wait_times.append(load_ms)
|
|||
|
|
network_wait_times[network_idx].append(load_ms)
|
|||
|
|
state.last_updated_ts = now
|
|||
|
|
|
|||
|
|
# 每个 session 间停留(含加载、浏览等)
|
|||
|
|
now += behavior_rng.uniform(8.0, 16.0)
|
|||
|
|
|
|||
|
|
# 网络状态抖动:同一用户跨会话可变更
|
|||
|
|
if session_index % 2 == 1 and outcome_rng.random() < 0.08:
|
|||
|
|
now += 0.0
|
|||
|
|
|
|||
|
|
avg_wait = safe_ratio(sum(total_wait_times), len(total_wait_times))
|
|||
|
|
p95_wait = percentile_95(total_wait_times)
|
|||
|
|
|
|||
|
|
total_requests = max(1, total_show_requests)
|
|||
|
|
total_preloads = max(1, total_preload_requests)
|
|||
|
|
network_avg_wait = []
|
|||
|
|
network_p95_wait = []
|
|||
|
|
for nw_idx in range(NETWORK_COUNT):
|
|||
|
|
network_avg_wait.append(safe_ratio(sum(network_wait_times[nw_idx]), max(1, len(network_wait_times[nw_idx]))))
|
|||
|
|
network_p95_wait.append(percentile_95(network_wait_times[nw_idx]))
|
|||
|
|
|
|||
|
|
return RunResult(
|
|||
|
|
mode=mode,
|
|||
|
|
retention=retention_next_day,
|
|||
|
|
threshold=threshold,
|
|||
|
|
cooldown_seconds=cooldown_seconds,
|
|||
|
|
total_users=len(robots),
|
|||
|
|
total_sessions=total_sessions,
|
|||
|
|
total_scene_entries=total_scene_entries,
|
|||
|
|
total_show_requests=total_show_requests,
|
|||
|
|
total_preload_requests=total_preload_requests,
|
|||
|
|
total_preload_success=total_preload_success,
|
|||
|
|
total_preload_failure=total_preload_failure,
|
|||
|
|
total_show_success=total_show_success,
|
|||
|
|
total_show_fail=total_show_fail,
|
|||
|
|
total_preload_waste=total_preload_waste,
|
|||
|
|
immediate_play_count=immediate_play_count,
|
|||
|
|
avg_wait_ms=avg_wait,
|
|||
|
|
p95_wait_ms=p95_wait,
|
|||
|
|
wasted_ratio=safe_ratio(total_preload_waste, total_preloads),
|
|||
|
|
preload_success_rate=safe_ratio(total_preload_success, total_preloads),
|
|||
|
|
show_success_rate=safe_ratio(total_show_success, total_requests),
|
|||
|
|
network_show_requests=tuple(network_show_requests),
|
|||
|
|
network_immediate_show=tuple(network_immediate_show),
|
|||
|
|
network_preload_requests=tuple(network_preload_requests),
|
|||
|
|
network_preload_success=tuple(network_preload_success),
|
|||
|
|
network_preload_waste=tuple(network_preload_waste),
|
|||
|
|
network_avg_wait_ms=(network_avg_wait[0], network_avg_wait[1], network_avg_wait[2], network_avg_wait[3]),
|
|||
|
|
network_p95_wait_ms=(network_p95_wait[0], network_p95_wait[1], network_p95_wait[2], network_p95_wait[3]),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def run_sensitivity(
|
|||
|
|
profiles: List[SceneProfile],
|
|||
|
|
policy_map: Dict[Tuple[int, str], Policy],
|
|||
|
|
robots: List[RobotProfile],
|
|||
|
|
threshold_grid: List[float],
|
|||
|
|
cooldown_grid: List[int],
|
|||
|
|
retention_rates: List[float],
|
|||
|
|
out_dir: Path,
|
|||
|
|
mode_manual_seed: int,
|
|||
|
|
) -> Tuple[List[RunResult], Dict[float, RunResult]]:
|
|||
|
|
baseline_by_retention: Dict[float, RunResult] = {}
|
|||
|
|
smart_results: List[RunResult] = []
|
|||
|
|
|
|||
|
|
for ridx, retention in enumerate(retention_rates):
|
|||
|
|
baseline_by_retention[retention] = simulate_once(
|
|||
|
|
profiles=profiles,
|
|||
|
|
policy_map=policy_map,
|
|||
|
|
robots=robots,
|
|||
|
|
threshold=1.0,
|
|||
|
|
cooldown_seconds=max(cooldown_grid),
|
|||
|
|
retention_next_day=retention,
|
|||
|
|
seed=mode_manual_seed + ridx * 1000,
|
|||
|
|
smart_enabled=False,
|
|||
|
|
mode="manual",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
for ridx, retention in enumerate(retention_rates):
|
|||
|
|
for tidx, threshold in enumerate(threshold_grid):
|
|||
|
|
for cidx, cooldown in enumerate(cooldown_grid):
|
|||
|
|
smart_results.append(
|
|||
|
|
simulate_once(
|
|||
|
|
profiles=profiles,
|
|||
|
|
policy_map=policy_map,
|
|||
|
|
robots=robots,
|
|||
|
|
threshold=threshold,
|
|||
|
|
cooldown_seconds=cooldown,
|
|||
|
|
retention_next_day=retention,
|
|||
|
|
seed=mode_manual_seed + 8000 + ridx * 1000 + tidx * 37 + cidx,
|
|||
|
|
smart_enabled=True,
|
|||
|
|
mode="smart",
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
summary_csv = out_dir / "smartload_sensitivity_summary.csv"
|
|||
|
|
with summary_csv.open("w", encoding="utf-8", newline="") as f:
|
|||
|
|
w = csv.writer(f)
|
|||
|
|
w.writerow(
|
|||
|
|
[
|
|||
|
|
"mode",
|
|||
|
|
"retention",
|
|||
|
|
"threshold",
|
|||
|
|
"cooldown",
|
|||
|
|
"total_show_requests",
|
|||
|
|
"total_show_success",
|
|||
|
|
"total_show_fail",
|
|||
|
|
"immediate_show",
|
|||
|
|
"avg_wait_ms",
|
|||
|
|
"p95_wait_ms",
|
|||
|
|
"total_preload_requests",
|
|||
|
|
"preload_success",
|
|||
|
|
"preload_fail",
|
|||
|
|
"preload_waste",
|
|||
|
|
"show_success_rate",
|
|||
|
|
"preload_success_rate",
|
|||
|
|
"wasted_ratio",
|
|||
|
|
"baseline_immediate_show",
|
|||
|
|
"baseline_avg_wait_ms",
|
|||
|
|
"delta_immediate_show",
|
|||
|
|
"delta_wait_ms",
|
|||
|
|
"delta_wait_pct",
|
|||
|
|
"delta_show_success",
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
for retention in retention_rates:
|
|||
|
|
manual = baseline_by_retention[retention]
|
|||
|
|
m_immediate = safe_ratio(manual.immediate_play_count, manual.total_show_requests)
|
|||
|
|
w.writerow(
|
|||
|
|
[
|
|||
|
|
"manual",
|
|||
|
|
f"{retention:.4f}",
|
|||
|
|
"1.00",
|
|||
|
|
max(cooldown_grid),
|
|||
|
|
manual.total_show_requests,
|
|||
|
|
manual.total_show_success,
|
|||
|
|
manual.total_show_fail,
|
|||
|
|
f"{m_immediate:.6f}",
|
|||
|
|
f"{manual.avg_wait_ms:.2f}",
|
|||
|
|
f"{manual.p95_wait_ms:.2f}",
|
|||
|
|
manual.total_preload_requests,
|
|||
|
|
manual.total_preload_success,
|
|||
|
|
manual.total_preload_failure,
|
|||
|
|
f"{manual.total_preload_waste}",
|
|||
|
|
f"{manual.show_success_rate:.6f}",
|
|||
|
|
f"{manual.preload_success_rate:.6f}",
|
|||
|
|
f"{manual.wasted_ratio:.6f}",
|
|||
|
|
f"{m_immediate:.6f}",
|
|||
|
|
f"{manual.avg_wait_ms:.2f}",
|
|||
|
|
f"{0:.6f}",
|
|||
|
|
f"{0:.2f}",
|
|||
|
|
f"{0:.6f}",
|
|||
|
|
f"{0:.6f}",
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
for r in smart_results:
|
|||
|
|
b = baseline_by_retention[r.retention]
|
|||
|
|
immediate = safe_ratio(r.immediate_play_count, r.total_show_requests)
|
|||
|
|
baseline_immediate = safe_ratio(b.immediate_play_count, b.total_show_requests)
|
|||
|
|
delta_immediate = immediate - baseline_immediate
|
|||
|
|
delta_wait = r.avg_wait_ms - b.avg_wait_ms
|
|||
|
|
delta_wait_pct = safe_ratio(delta_wait, b.avg_wait_ms) if b.avg_wait_ms > 0 else 0.0
|
|||
|
|
w.writerow(
|
|||
|
|
[
|
|||
|
|
r.mode,
|
|||
|
|
f"{r.retention:.4f}",
|
|||
|
|
f"{r.threshold:.4f}",
|
|||
|
|
r.cooldown_seconds,
|
|||
|
|
r.total_show_requests,
|
|||
|
|
r.total_show_success,
|
|||
|
|
r.total_show_fail,
|
|||
|
|
f"{immediate:.6f}",
|
|||
|
|
f"{r.avg_wait_ms:.2f}",
|
|||
|
|
f"{r.p95_wait_ms:.2f}",
|
|||
|
|
r.total_preload_requests,
|
|||
|
|
r.total_preload_success,
|
|||
|
|
r.total_preload_failure,
|
|||
|
|
r.total_preload_waste,
|
|||
|
|
f"{r.show_success_rate:.6f}",
|
|||
|
|
f"{r.preload_success_rate:.6f}",
|
|||
|
|
f"{r.wasted_ratio:.6f}",
|
|||
|
|
f"{baseline_immediate:.6f}",
|
|||
|
|
f"{b.avg_wait_ms:.2f}",
|
|||
|
|
f"{delta_immediate:.6f}",
|
|||
|
|
f"{delta_wait:.2f}",
|
|||
|
|
f"{delta_wait_pct:.6f}",
|
|||
|
|
f"{(r.show_success_rate - b.show_success_rate):.6f}",
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
rank_csv = write_rank_csv(smart_results, baseline_by_retention, out_dir)
|
|||
|
|
write_mode_comparison_csv(baseline_by_retention, smart_results, out_dir)
|
|||
|
|
write_robot_summary_csv(robots, out_dir)
|
|||
|
|
write_mode_lines(baseline_by_retention, smart_results, out_dir)
|
|||
|
|
write_network_mode_comparison_csv(baseline_by_retention, smart_results, out_dir)
|
|||
|
|
draw_network_mode_lines(baseline_by_retention, smart_results, out_dir)
|
|||
|
|
|
|||
|
|
return smart_results, baseline_by_retention
|
|||
|
|
|
|||
|
|
|
|||
|
|
def write_rank_csv(results: List[RunResult], baseline_by_retention: Dict[float, RunResult], out_dir: Path) -> Path:
|
|||
|
|
rank_csv = out_dir / "smartload_retention_rank.csv"
|
|||
|
|
with rank_csv.open("w", encoding="utf-8", newline="") as f:
|
|||
|
|
w = csv.writer(f)
|
|||
|
|
w.writerow(
|
|||
|
|
[
|
|||
|
|
"retention",
|
|||
|
|
"rank",
|
|||
|
|
"threshold",
|
|||
|
|
"cooldown",
|
|||
|
|
"immediate_show",
|
|||
|
|
"avg_wait_ms",
|
|||
|
|
"p95_wait_ms",
|
|||
|
|
"waste_ratio",
|
|||
|
|
"show_success_rate",
|
|||
|
|
"delta_immediate",
|
|||
|
|
"delta_wait_ms",
|
|||
|
|
"delta_wait_pct",
|
|||
|
|
"objective_score",
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
for retention in sorted(baseline_by_retention.keys()):
|
|||
|
|
rows = []
|
|||
|
|
for r in [x for x in results if x.retention == retention]:
|
|||
|
|
b = baseline_by_retention[retention]
|
|||
|
|
immediate = safe_ratio(r.immediate_play_count, r.total_show_requests)
|
|||
|
|
baseline_immediate = safe_ratio(b.immediate_play_count, b.total_show_requests)
|
|||
|
|
delta_immediate = immediate - baseline_immediate
|
|||
|
|
delta_wait = r.avg_wait_ms - b.avg_wait_ms
|
|||
|
|
score = 0.7 * delta_immediate + 0.2 * safe_ratio((1 - r.wasted_ratio), 1.0) - 0.001 * r.avg_wait_ms / 10.0
|
|||
|
|
rows.append((score, r))
|
|||
|
|
rows.sort(key=lambda x: x[0], reverse=True)
|
|||
|
|
for idx, (_, r) in enumerate(rows, start=1):
|
|||
|
|
immediate = safe_ratio(r.immediate_play_count, r.total_show_requests)
|
|||
|
|
b = baseline_by_retention[retention]
|
|||
|
|
delta_immediate = immediate - safe_ratio(b.immediate_play_count, b.total_show_requests)
|
|||
|
|
delta_wait = r.avg_wait_ms - b.avg_wait_ms
|
|||
|
|
delta_wait_pct = safe_ratio(delta_wait, b.avg_wait_ms) if b.avg_wait_ms > 0 else 0.0
|
|||
|
|
score = 0.7 * delta_immediate + 0.2 * (1 - r.wasted_ratio) - 0.001 * r.avg_wait_ms / 10.0
|
|||
|
|
w.writerow(
|
|||
|
|
[
|
|||
|
|
f"{retention:.2f}",
|
|||
|
|
idx,
|
|||
|
|
f"{r.threshold:.2f}",
|
|||
|
|
r.cooldown_seconds,
|
|||
|
|
f"{immediate:.6f}",
|
|||
|
|
f"{r.avg_wait_ms:.2f}",
|
|||
|
|
f"{r.p95_wait_ms:.2f}",
|
|||
|
|
f"{r.wasted_ratio:.6f}",
|
|||
|
|
f"{r.show_success_rate:.6f}",
|
|||
|
|
f"{delta_immediate:.6f}",
|
|||
|
|
f"{delta_wait:.2f}",
|
|||
|
|
f"{delta_wait_pct:.6f}",
|
|||
|
|
f"{score:.6f}",
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
return rank_csv
|
|||
|
|
|
|||
|
|
|
|||
|
|
def write_mode_comparison_csv(
|
|||
|
|
baseline_by_retention: Dict[float, RunResult],
|
|||
|
|
smart_results: List[RunResult],
|
|||
|
|
out_dir: Path,
|
|||
|
|
) -> Path:
|
|||
|
|
by_retention = defaultdict(list)
|
|||
|
|
for result in smart_results:
|
|||
|
|
by_retention[result.retention].append(result)
|
|||
|
|
|
|||
|
|
out = out_dir / "smartload_mode_comparison.csv"
|
|||
|
|
with out.open("w", encoding="utf-8", newline="") as f:
|
|||
|
|
w = csv.writer(f)
|
|||
|
|
w.writerow(
|
|||
|
|
[
|
|||
|
|
"retention",
|
|||
|
|
"manual_immediate",
|
|||
|
|
"manual_avg_wait_ms",
|
|||
|
|
"manual_waste_ratio",
|
|||
|
|
"manual_show_success_rate",
|
|||
|
|
"smart_threshold",
|
|||
|
|
"smart_cooldown",
|
|||
|
|
"smart_immediate",
|
|||
|
|
"smart_avg_wait_ms",
|
|||
|
|
"smart_waste_ratio",
|
|||
|
|
"smart_show_success_rate",
|
|||
|
|
"delta_immediate",
|
|||
|
|
"delta_wait_ms",
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
for retention, b in sorted(baseline_by_retention.items()):
|
|||
|
|
manual_immediate = safe_ratio(b.immediate_play_count, b.total_show_requests)
|
|||
|
|
candidates = by_retention[retention]
|
|||
|
|
if not candidates:
|
|||
|
|
continue
|
|||
|
|
best = max(candidates, key=lambda item: safe_ratio(item.immediate_play_count, item.total_show_requests))
|
|||
|
|
smart_immediate = safe_ratio(best.immediate_play_count, best.total_show_requests)
|
|||
|
|
w.writerow(
|
|||
|
|
[
|
|||
|
|
f"{retention:.2f}",
|
|||
|
|
f"{manual_immediate:.6f}",
|
|||
|
|
f"{b.avg_wait_ms:.2f}",
|
|||
|
|
f"{b.wasted_ratio:.6f}",
|
|||
|
|
f"{b.show_success_rate:.6f}",
|
|||
|
|
f"{best.threshold:.2f}",
|
|||
|
|
best.cooldown_seconds,
|
|||
|
|
f"{smart_immediate:.6f}",
|
|||
|
|
f"{best.avg_wait_ms:.2f}",
|
|||
|
|
f"{best.wasted_ratio:.6f}",
|
|||
|
|
f"{best.show_success_rate:.6f}",
|
|||
|
|
f"{smart_immediate - manual_immediate:.6f}",
|
|||
|
|
f"{best.avg_wait_ms - b.avg_wait_ms:.2f}",
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
def write_network_mode_comparison_csv(
|
|||
|
|
baseline_by_retention: Dict[float, RunResult],
|
|||
|
|
smart_results: List[RunResult],
|
|||
|
|
out_dir: Path,
|
|||
|
|
) -> Path:
|
|||
|
|
by_retention = defaultdict(list)
|
|||
|
|
for result in smart_results:
|
|||
|
|
by_retention[result.retention].append(result)
|
|||
|
|
|
|||
|
|
out = out_dir / "smartload_network_mode_comparison.csv"
|
|||
|
|
with out.open("w", encoding="utf-8", newline="") as f:
|
|||
|
|
w = csv.writer(f)
|
|||
|
|
w.writerow(
|
|||
|
|
[
|
|||
|
|
"retention",
|
|||
|
|
"network",
|
|||
|
|
"manual_show_requests",
|
|||
|
|
"manual_immediate",
|
|||
|
|
"manual_avg_wait_ms",
|
|||
|
|
"manual_p95_wait_ms",
|
|||
|
|
"manual_waste_ratio",
|
|||
|
|
"manual_preload_success",
|
|||
|
|
"smart_threshold",
|
|||
|
|
"smart_cooldown",
|
|||
|
|
"smart_show_requests",
|
|||
|
|
"smart_immediate",
|
|||
|
|
"smart_avg_wait_ms",
|
|||
|
|
"smart_p95_wait_ms",
|
|||
|
|
"smart_waste_ratio",
|
|||
|
|
"smart_preload_success",
|
|||
|
|
"delta_immediate",
|
|||
|
|
"delta_wait_ms",
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
for retention, b in sorted(baseline_by_retention.items()):
|
|||
|
|
candidates = by_retention[retention]
|
|||
|
|
for idx, network_name in enumerate(NETWORK_LABELS):
|
|||
|
|
manual_immediate = safe_ratio(b.network_immediate_show[idx], b.network_show_requests[idx])
|
|||
|
|
manual_wait = b.network_avg_wait_ms[idx]
|
|||
|
|
manual_p95 = b.network_p95_wait_ms[idx]
|
|||
|
|
manual_waste = safe_ratio(b.network_preload_waste[idx], max(1, b.network_preload_requests[idx]))
|
|||
|
|
manual_preload_success = safe_ratio(b.network_preload_success[idx], max(1, b.network_preload_requests[idx]))
|
|||
|
|
|
|||
|
|
best = None
|
|||
|
|
if candidates:
|
|||
|
|
best = max(candidates, key=lambda item: safe_ratio(item.network_immediate_show[idx], item.network_show_requests[idx]))
|
|||
|
|
|
|||
|
|
if best is None:
|
|||
|
|
w.writerow(
|
|||
|
|
[
|
|||
|
|
f"{retention:.2f}",
|
|||
|
|
network_name,
|
|||
|
|
b.network_show_requests[idx],
|
|||
|
|
f"{manual_immediate:.6f}",
|
|||
|
|
f"{manual_wait:.2f}",
|
|||
|
|
f"{manual_p95:.2f}",
|
|||
|
|
f"{manual_waste:.6f}",
|
|||
|
|
f"{manual_preload_success:.6f}",
|
|||
|
|
"N/A",
|
|||
|
|
"N/A",
|
|||
|
|
0,
|
|||
|
|
f"{0:.6f}",
|
|||
|
|
f"{0:.2f}",
|
|||
|
|
f"{0:.2f}",
|
|||
|
|
f"{0:.6f}",
|
|||
|
|
f"{0:.6f}",
|
|||
|
|
f"{0:.6f}",
|
|||
|
|
f"{0:.2f}",
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
smart_immediate = safe_ratio(best.network_immediate_show[idx], best.network_show_requests[idx])
|
|||
|
|
smart_wait = best.network_avg_wait_ms[idx]
|
|||
|
|
smart_p95 = best.network_p95_wait_ms[idx]
|
|||
|
|
smart_waste = safe_ratio(best.network_preload_waste[idx], max(1, best.network_preload_requests[idx]))
|
|||
|
|
smart_preload_success = safe_ratio(best.network_preload_success[idx], max(1, best.network_preload_requests[idx]))
|
|||
|
|
|
|||
|
|
w.writerow(
|
|||
|
|
[
|
|||
|
|
f"{retention:.2f}",
|
|||
|
|
network_name,
|
|||
|
|
b.network_show_requests[idx],
|
|||
|
|
f"{manual_immediate:.6f}",
|
|||
|
|
f"{manual_wait:.2f}",
|
|||
|
|
f"{manual_p95:.2f}",
|
|||
|
|
f"{manual_waste:.6f}",
|
|||
|
|
f"{manual_preload_success:.6f}",
|
|||
|
|
f"{best.threshold:.2f}",
|
|||
|
|
best.cooldown_seconds,
|
|||
|
|
best.network_show_requests[idx],
|
|||
|
|
f"{smart_immediate:.6f}",
|
|||
|
|
f"{smart_wait:.2f}",
|
|||
|
|
f"{smart_p95:.2f}",
|
|||
|
|
f"{smart_waste:.6f}",
|
|||
|
|
f"{smart_preload_success:.6f}",
|
|||
|
|
f"{smart_immediate - manual_immediate:.6f}",
|
|||
|
|
f"{smart_wait - manual_wait:.2f}",
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
def write_robot_summary_csv(robots: List[RobotProfile], out_dir: Path) -> Path:
|
|||
|
|
summary_csv = out_dir / "smartload_robot_cohorts.csv"
|
|||
|
|
with summary_csv.open("w", encoding="utf-8", newline="") as f:
|
|||
|
|
w = csv.writer(f)
|
|||
|
|
w.writerow(
|
|||
|
|
[
|
|||
|
|
"robot_id",
|
|||
|
|
"archetype",
|
|||
|
|
"enter_reward",
|
|||
|
|
"enter_interstitial",
|
|||
|
|
"enter_splash",
|
|||
|
|
"request_reward",
|
|||
|
|
"request_interstitial",
|
|||
|
|
"request_splash",
|
|||
|
|
"fill_affinity",
|
|||
|
|
"latency_affinity",
|
|||
|
|
"retention_affinity",
|
|||
|
|
"network_wifi",
|
|||
|
|
"network_4g",
|
|||
|
|
"network_3g",
|
|||
|
|
"network_2g",
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
for robot in robots:
|
|||
|
|
w.writerow(
|
|||
|
|
[
|
|||
|
|
robot.robot_id,
|
|||
|
|
robot.archetype,
|
|||
|
|
f"{robot.enter_multipliers[0]:.4f}",
|
|||
|
|
f"{robot.enter_multipliers[1]:.4f}",
|
|||
|
|
f"{robot.enter_multipliers[2]:.4f}",
|
|||
|
|
f"{robot.request_multipliers[0]:.4f}",
|
|||
|
|
f"{robot.request_multipliers[1]:.4f}",
|
|||
|
|
f"{robot.request_multipliers[2]:.4f}",
|
|||
|
|
f"{robot.fill_affinity:.4f}",
|
|||
|
|
f"{robot.latency_affinity:.4f}",
|
|||
|
|
f"{robot.retention_affinity:.4f}",
|
|||
|
|
f"{robot.network_weights[0]:.6f}",
|
|||
|
|
f"{robot.network_weights[1]:.6f}",
|
|||
|
|
f"{robot.network_weights[2]:.6f}",
|
|||
|
|
f"{robot.network_weights[3]:.6f}",
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 一页式摘要也可用于报告快速读取。
|
|||
|
|
summary_text = out_dir / "smartload_robot_cohort_summary.txt"
|
|||
|
|
counter = Counter(robot.archetype for robot in robots)
|
|||
|
|
with summary_text.open("w", encoding="utf-8") as out:
|
|||
|
|
out.write("Robot Cohort Summary\n")
|
|||
|
|
out.write("===================\n")
|
|||
|
|
for name, count in counter.most_common():
|
|||
|
|
out.write(f"{name}: {count}\n")
|
|||
|
|
|
|||
|
|
return summary_csv
|
|||
|
|
|
|||
|
|
|
|||
|
|
def draw_heatmaps(results: List[RunResult], retention: float, out_dir: Path):
|
|||
|
|
filtered = [r for r in results if abs(r.retention - retention) < 1e-6]
|
|||
|
|
if not filtered:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
thresholds = sorted({r.threshold for r in filtered})
|
|||
|
|
cooldowns = sorted({r.cooldown_seconds for r in filtered})
|
|||
|
|
index = {(r.threshold, r.cooldown_seconds): r for r in filtered}
|
|||
|
|
|
|||
|
|
def matrix(metric):
|
|||
|
|
data = [[0.0 for _ in cooldowns] for _ in thresholds]
|
|||
|
|
for ti, t in enumerate(thresholds):
|
|||
|
|
for ci, c in enumerate(cooldowns):
|
|||
|
|
r = index[(t, c)]
|
|||
|
|
if metric == "immediate":
|
|||
|
|
data[ti][ci] = safe_ratio(r.immediate_play_count, r.total_show_requests)
|
|||
|
|
elif metric == "wait":
|
|||
|
|
data[ti][ci] = r.avg_wait_ms
|
|||
|
|
elif metric == "waste":
|
|||
|
|
data[ti][ci] = r.wasted_ratio
|
|||
|
|
elif metric == "preload":
|
|||
|
|
data[ti][ci] = r.preload_success_rate
|
|||
|
|
return data
|
|||
|
|
|
|||
|
|
def plot(title, data, fname, label):
|
|||
|
|
plt.figure(figsize=(8, 6))
|
|||
|
|
im = plt.imshow(data, origin="lower", aspect="auto", cmap="viridis")
|
|||
|
|
plt.yticks(range(len(thresholds)), [f"{t:.2f}" for t in thresholds])
|
|||
|
|
plt.xticks(range(len(cooldowns)), [str(c) for c in cooldowns])
|
|||
|
|
plt.xlabel("冷却时间(秒)")
|
|||
|
|
plt.ylabel("预加载阈值")
|
|||
|
|
plt.title(title)
|
|||
|
|
cbar = plt.colorbar(im)
|
|||
|
|
cbar.set_label(label)
|
|||
|
|
plt.tight_layout()
|
|||
|
|
plt.savefig(out_dir / fname, dpi=150)
|
|||
|
|
plt.close()
|
|||
|
|
|
|||
|
|
plot(f"智能预加载即时命中率(次留={retention:.0%})", matrix("immediate"), f"heatmap_immediate_r_{retention:.2f}.png", "即时命中率")
|
|||
|
|
plot(f"智能预加载平均等待(次留={retention:.0%})", matrix("wait"), f"heatmap_wait_ms_r_{retention:.2f}.png", "平均等待(ms)")
|
|||
|
|
plot(f"智能预加载浪费比例(次留={retention:.0%})", matrix("waste"), f"heatmap_waste_ratio_r_{retention:.2f}.png", "浪费比例")
|
|||
|
|
plot(f"智能预加载预加载成功率(次留={retention:.0%})", matrix("preload"), f"heatmap_preload_success_r_{retention:.2f}.png", "预加载成功率")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def draw_retention_lines(results: List[RunResult], out_dir: Path):
|
|||
|
|
thresholds = sorted({r.threshold for r in results})
|
|||
|
|
cooldowns = sorted({r.cooldown_seconds for r in results})
|
|||
|
|
retentions = sorted({r.retention for r in results})
|
|||
|
|
baseline_threshold = thresholds[len(thresholds) // 2]
|
|||
|
|
|
|||
|
|
plt.figure(figsize=(10, 6))
|
|||
|
|
for c in cooldowns:
|
|||
|
|
xs = []
|
|||
|
|
ys = []
|
|||
|
|
for r in sorted([x for x in results if x.cooldown_seconds == c and x.threshold == baseline_threshold], key=lambda x: x.retention):
|
|||
|
|
xs.append(r.retention)
|
|||
|
|
ys.append(safe_ratio(r.immediate_play_count, r.total_show_requests))
|
|||
|
|
plt.plot(xs, ys, marker="o", label=f"cd={c}s")
|
|||
|
|
plt.title("敏感度:次留与即时命中率")
|
|||
|
|
plt.xlabel("次留(D2)")
|
|||
|
|
plt.ylabel("即时命中率")
|
|||
|
|
plt.grid(True)
|
|||
|
|
plt.legend(fontsize=8)
|
|||
|
|
plt.tight_layout()
|
|||
|
|
plt.savefig(out_dir / "line_immediate_vs_retention.png", dpi=150)
|
|||
|
|
plt.close()
|
|||
|
|
|
|||
|
|
plt.figure(figsize=(10, 6))
|
|||
|
|
for c in cooldowns:
|
|||
|
|
xs = []
|
|||
|
|
ys = []
|
|||
|
|
for r in sorted([x for x in results if x.cooldown_seconds == c and x.threshold == baseline_threshold], key=lambda x: x.retention):
|
|||
|
|
xs.append(r.retention)
|
|||
|
|
ys.append(r.avg_wait_ms)
|
|||
|
|
plt.plot(xs, ys, marker="s", label=f"cd={c}s")
|
|||
|
|
plt.title("敏感度:次留与平均等待")
|
|||
|
|
plt.xlabel("次留(D2)")
|
|||
|
|
plt.ylabel("平均等待(ms)")
|
|||
|
|
plt.grid(True)
|
|||
|
|
plt.legend(fontsize=8)
|
|||
|
|
plt.tight_layout()
|
|||
|
|
plt.savefig(out_dir / "line_wait_vs_retention.png", dpi=150)
|
|||
|
|
plt.close()
|
|||
|
|
|
|||
|
|
for retention in retentions:
|
|||
|
|
same_r = sorted([x for x in results if abs(x.retention - retention) < 1e-6 and x.threshold == baseline_threshold], key=lambda x: x.cooldown_seconds)
|
|||
|
|
if not same_r:
|
|||
|
|
continue
|
|||
|
|
fig, ax1 = plt.subplots(figsize=(10, 6))
|
|||
|
|
ax2 = ax1.twinx()
|
|||
|
|
cooldown_values = [x.cooldown_seconds for x in same_r]
|
|||
|
|
immediate = [safe_ratio(x.immediate_play_count, x.total_show_requests) for x in same_r]
|
|||
|
|
waste = [x.wasted_ratio for x in same_r]
|
|||
|
|
ax1.plot(cooldown_values, immediate, marker="o", label="即时命中率")
|
|||
|
|
ax1.set_xlabel("冷却时间(秒)")
|
|||
|
|
ax1.set_ylabel("即时命中率", color="#1f77b4")
|
|||
|
|
ax2.plot(cooldown_values, waste, marker="x", color="#F28D35", label="浪费比例")
|
|||
|
|
ax2.set_ylabel("浪费比例", color="#F28D35")
|
|||
|
|
ax1.set_title(f"次留={retention:.0%},阈值={baseline_threshold:.2f}")
|
|||
|
|
ax1.grid(True)
|
|||
|
|
ax1.set_xlim(left=min(cooldown_values), right=max(cooldown_values))
|
|||
|
|
fig.tight_layout()
|
|||
|
|
fig.savefig(out_dir / f"line_threshold_{baseline_threshold:.2f}_retention_{retention:.2f}.png", dpi=150)
|
|||
|
|
plt.close(fig)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def write_mode_lines(
|
|||
|
|
baseline_by_retention: Dict[float, RunResult],
|
|||
|
|
smart_results: List[RunResult],
|
|||
|
|
out_dir: Path,
|
|||
|
|
) -> None:
|
|||
|
|
best_by_retention: Dict[float, RunResult] = {}
|
|||
|
|
for retention, baseline in baseline_by_retention.items():
|
|||
|
|
candidates = [r for r in smart_results if r.retention == retention]
|
|||
|
|
if not candidates:
|
|||
|
|
continue
|
|||
|
|
best = max(candidates, key=lambda x: safe_ratio(x.immediate_play_count, x.total_show_requests))
|
|||
|
|
best_by_retention[retention] = best
|
|||
|
|
|
|||
|
|
retentions = sorted(baseline_by_retention.keys())
|
|||
|
|
|
|||
|
|
plt.figure(figsize=(10, 6))
|
|||
|
|
xs = [r for r in retentions]
|
|||
|
|
ys_manual = [safe_ratio(baseline_by_retention[r].immediate_play_count, baseline_by_retention[r].total_show_requests) for r in retentions]
|
|||
|
|
ys_smart = [safe_ratio(best_by_retention[r].immediate_play_count, best_by_retention[r].total_show_requests) for r in retentions]
|
|||
|
|
plt.plot(xs, ys_manual, marker="o", label="手动(无智能)")
|
|||
|
|
plt.plot(xs, ys_smart, marker="o", label="智能(最优配置)")
|
|||
|
|
plt.title("模式对比:次留与即时播放率")
|
|||
|
|
plt.xlabel("次留(D2)")
|
|||
|
|
plt.ylabel("即时播放率")
|
|||
|
|
plt.grid(True)
|
|||
|
|
plt.legend()
|
|||
|
|
plt.tight_layout()
|
|||
|
|
plt.savefig(out_dir / "line_mode_immediate_vs_retention.png", dpi=150)
|
|||
|
|
plt.close()
|
|||
|
|
|
|||
|
|
plt.figure(figsize=(10, 6))
|
|||
|
|
ys_manual_wait = [baseline_by_retention[r].avg_wait_ms for r in retentions]
|
|||
|
|
ys_smart_wait = [best_by_retention[r].avg_wait_ms for r in retentions]
|
|||
|
|
plt.plot(xs, ys_manual_wait, marker="s", label="手动(无智能)")
|
|||
|
|
plt.plot(xs, ys_smart_wait, marker="s", label="智能(最优配置)")
|
|||
|
|
plt.title("模式对比:次留与平均等待")
|
|||
|
|
plt.xlabel("次留(D2)")
|
|||
|
|
plt.ylabel("平均等待(ms)")
|
|||
|
|
plt.grid(True)
|
|||
|
|
plt.legend()
|
|||
|
|
plt.tight_layout()
|
|||
|
|
plt.savefig(out_dir / "line_mode_wait_vs_retention.png", dpi=150)
|
|||
|
|
plt.close()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def draw_network_mode_lines(
|
|||
|
|
baseline_by_retention: Dict[float, RunResult],
|
|||
|
|
smart_results: List[RunResult],
|
|||
|
|
out_dir: Path,
|
|||
|
|
) -> None:
|
|||
|
|
by_retention = defaultdict(list)
|
|||
|
|
for result in smart_results:
|
|||
|
|
by_retention[result.retention].append(result)
|
|||
|
|
|
|||
|
|
retentions = sorted(baseline_by_retention.keys())
|
|||
|
|
|
|||
|
|
plt.figure(figsize=(11, 7))
|
|||
|
|
for idx, network_name in enumerate(NETWORK_LABELS):
|
|||
|
|
xs = []
|
|||
|
|
ys_manual = []
|
|||
|
|
ys_smart = []
|
|||
|
|
for retention in retentions:
|
|||
|
|
b = baseline_by_retention[retention]
|
|||
|
|
xs.append(retention)
|
|||
|
|
ys_manual.append(
|
|||
|
|
safe_ratio(
|
|||
|
|
b.network_immediate_show[idx],
|
|||
|
|
b.network_show_requests[idx],
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
candidates = by_retention[retention]
|
|||
|
|
if candidates:
|
|||
|
|
best = max(
|
|||
|
|
candidates,
|
|||
|
|
key=lambda item: safe_ratio(item.network_immediate_show[idx], item.network_show_requests[idx]),
|
|||
|
|
)
|
|||
|
|
ys_smart.append(
|
|||
|
|
safe_ratio(
|
|||
|
|
best.network_immediate_show[idx],
|
|||
|
|
best.network_show_requests[idx],
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
ys_smart.append(0.0)
|
|||
|
|
|
|||
|
|
plt.plot(xs, ys_manual, marker="o", label=f"{network_name.upper()} - 手动")
|
|||
|
|
plt.plot(xs, ys_smart, marker="s", linestyle="--", label=f"{network_name.upper()} - 智能")
|
|||
|
|
|
|||
|
|
plt.title("网络分层即时命中率:次留对比(D2)")
|
|||
|
|
plt.xlabel("次留(D2)")
|
|||
|
|
plt.ylabel("即时命中率")
|
|||
|
|
plt.grid(True)
|
|||
|
|
plt.legend(fontsize=8)
|
|||
|
|
plt.tight_layout()
|
|||
|
|
plt.savefig(out_dir / "line_network_immediate_vs_retention.png", dpi=150)
|
|||
|
|
plt.close()
|
|||
|
|
|
|||
|
|
plt.figure(figsize=(11, 7))
|
|||
|
|
for idx, network_name in enumerate(NETWORK_LABELS):
|
|||
|
|
xs = []
|
|||
|
|
ys_manual = []
|
|||
|
|
ys_smart = []
|
|||
|
|
for retention in retentions:
|
|||
|
|
b = baseline_by_retention[retention]
|
|||
|
|
xs.append(retention)
|
|||
|
|
ys_manual.append(b.network_avg_wait_ms[idx])
|
|||
|
|
|
|||
|
|
candidates = by_retention[retention]
|
|||
|
|
if candidates:
|
|||
|
|
best = max(
|
|||
|
|
candidates,
|
|||
|
|
key=lambda item: safe_ratio(item.network_immediate_show[idx], item.network_show_requests[idx]),
|
|||
|
|
)
|
|||
|
|
ys_smart.append(best.network_avg_wait_ms[idx])
|
|||
|
|
else:
|
|||
|
|
ys_smart.append(0.0)
|
|||
|
|
|
|||
|
|
plt.plot(xs, ys_manual, marker="o", label=f"{network_name.upper()} - 手动")
|
|||
|
|
plt.plot(xs, ys_smart, marker="s", linestyle="--", label=f"{network_name.upper()} - 智能")
|
|||
|
|
|
|||
|
|
plt.title("网络分层平均等待:次留对比(D2)")
|
|||
|
|
plt.xlabel("次留(D2)")
|
|||
|
|
plt.ylabel("平均等待(ms)")
|
|||
|
|
plt.grid(True)
|
|||
|
|
plt.legend(fontsize=8)
|
|||
|
|
plt.tight_layout()
|
|||
|
|
plt.savefig(out_dir / "line_network_wait_vs_retention.png", dpi=150)
|
|||
|
|
plt.close()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_report(
|
|||
|
|
results: List[RunResult],
|
|||
|
|
baseline_by_retention: Dict[float, RunResult],
|
|||
|
|
out_dir: Path,
|
|||
|
|
robot_count: int,
|
|||
|
|
baseline_retention_default: float = 0.35,
|
|||
|
|
) -> Path:
|
|||
|
|
report = out_dir / "TapADN_智能预加载_敏感度验收报告.md"
|
|||
|
|
ranked: Dict[float, List[RunResult]] = defaultdict(list)
|
|||
|
|
for r in results:
|
|||
|
|
ranked[r.retention].append(r)
|
|||
|
|
|
|||
|
|
ranked_sorted: Dict[float, List[RunResult]] = {}
|
|||
|
|
for retention, rows in ranked.items():
|
|||
|
|
ranked_sorted[retention] = sorted(rows, key=lambda item: safe_ratio(item.immediate_play_count, item.total_show_requests), reverse=True)
|
|||
|
|
|
|||
|
|
def pick_balanced(rows: List[RunResult], waste_cap: float = 0.12) -> RunResult | None:
|
|||
|
|
filtered = [r for r in rows if r.wasted_ratio <= waste_cap]
|
|||
|
|
if not filtered:
|
|||
|
|
return None
|
|||
|
|
return max(filtered, key=lambda item: safe_ratio(item.immediate_play_count, item.total_show_requests))
|
|||
|
|
|
|||
|
|
def format_table_rows(rows: List[RunResult], count: int = 8) -> List[str]:
|
|||
|
|
lines = []
|
|||
|
|
for idx, r in enumerate(rows[:count], 1):
|
|||
|
|
immediate = safe_ratio(r.immediate_play_count, r.total_show_requests)
|
|||
|
|
lines.append(
|
|||
|
|
f"| {idx} | {r.threshold:.2f} | {r.cooldown_seconds} | {immediate:.2%} | {r.avg_wait_ms:.0f} | {r.p95_wait_ms:.0f} | {r.wasted_ratio:.2%} | {r.show_success_rate:.2%} |"
|
|||
|
|
)
|
|||
|
|
return lines
|
|||
|
|
|
|||
|
|
best_retention_map: Dict[float, RunResult] = {}
|
|||
|
|
for retention, rows in ranked_sorted.items():
|
|||
|
|
best_retention_map[retention] = rows[0]
|
|||
|
|
|
|||
|
|
default_baseline = baseline_by_retention[baseline_retention_default]
|
|||
|
|
default_rows = ranked_sorted[baseline_retention_default]
|
|||
|
|
best_default = default_rows[0]
|
|||
|
|
balanced_default = pick_balanced(default_rows, waste_cap=0.12)
|
|||
|
|
|
|||
|
|
b_immediate = safe_ratio(default_baseline.immediate_play_count, default_baseline.total_show_requests)
|
|||
|
|
|
|||
|
|
with report.open("w", encoding="utf-8") as f:
|
|||
|
|
f.write("# TapADN 智能预加载策略验收报告(含随机偏好机器人 + 网络环境)\n\n")
|
|||
|
|
f.write("## 仿真前提\n")
|
|||
|
|
f.write("- 模型定位:IAA 场景中 3 类广告位(激励/开屏/插屏)统一参与策略决策。\n")
|
|||
|
|
f.write("- 次留默认验收基线:35%\n")
|
|||
|
|
f.write(f"- 随机机器人数量:{robot_count}\n")
|
|||
|
|
f.write("- 场景进入->展示请求->预加载决策->展示时延为核心链路。\n")
|
|||
|
|
f.write("- 每次样本运行前先生成一批“偏好机器人”,再在其上分别运行:纯手动(无 smart)与智能预加载两种模式。\n")
|
|||
|
|
f.write("- 预加载触发后在 cooldown 内有效一次,不命中将视作普通 load。\n")
|
|||
|
|
f.write("- `Immediate`:请求时已命中可直接播放的占比(值越高越好)\n")
|
|||
|
|
f.write("- `Waste`:预加载后在 cooldown 内未被消费即失效的比例(值越低越好)\n")
|
|||
|
|
f.write("- 网络环境:Wi-Fi / 4G / 3G / 2G 按机器人偏好加权采样,逐回合独立变化。\n\n")
|
|||
|
|
|
|||
|
|
f.write("## 机器人与网络设置\n")
|
|||
|
|
f.write("- 机器人类型:随机采样以下偏好族(reward_heavy、interstitial_focus、splash_driven、balanced、churn_sensitive、network_bound)。\n")
|
|||
|
|
f.write("- 每类机器人具有独立的场景进入偏好、请求偏好、fill 成功倍率、加载时延倍率和留存倍率。\n")
|
|||
|
|
f.write("- 网络环境以会话粒度采样,低网速会同步影响网络请求率、fill 成功率和加载耗时。\n\n")
|
|||
|
|
|
|||
|
|
f.write("## 参数扫描范围\n")
|
|||
|
|
f.write("- Threshold: `0.20~0.90` 步长 0.05\n")
|
|||
|
|
f.write("- Cooldown: `30,60,90,120,180,240,300`\n")
|
|||
|
|
f.write("- 次留:`20%,25%,30%,35%,40%,50%,60%`\n\n")
|
|||
|
|
|
|||
|
|
f.write("## 基线对照\n")
|
|||
|
|
f.write(f"- 次留 35% 基线(纯手动,无 smart)即时命中率:{b_immediate:.2%}\n")
|
|||
|
|
f.write(f"- 次留 35% 基线平均时延:{default_baseline.avg_wait_ms:.0f} ms(p95 {default_baseline.p95_wait_ms:.0f})\n")
|
|||
|
|
f.write(f"- 次留 35% 建议起点(当前模型):`threshold={best_default.threshold:.2f}, cooldown={best_default.cooldown_seconds}s`\n")
|
|||
|
|
f.write(
|
|||
|
|
f" - 对比基线即时命中提升:`{safe_ratio(best_default.immediate_play_count, best_default.total_show_requests)-b_immediate:.2%}`\n"
|
|||
|
|
)
|
|||
|
|
f.write(f" - 对比基线时延变化:`{best_default.avg_wait_ms - default_baseline.avg_wait_ms:.0f} ms`\n")
|
|||
|
|
if balanced_default is not None:
|
|||
|
|
f.write("- 平衡候选(Waste<=12%):\n")
|
|||
|
|
f.write(
|
|||
|
|
f" - threshold={balanced_default.threshold:.2f}, cd={balanced_default.cooldown_seconds}s,"
|
|||
|
|
f"Immediate {safe_ratio(balanced_default.immediate_play_count, balanced_default.total_show_requests):.2%},"
|
|||
|
|
f"Waste {balanced_default.wasted_ratio:.2%}\n"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
f.write("\n## 最优/最差(次留=35%)\n")
|
|||
|
|
f.write("| 排名 | Threshold | Cooldown | 即时命中率 | 平均等待 | P95等待 | 浪费率 | 播放成功率 |\n")
|
|||
|
|
f.write("|---|---:|---:|---:|---:|---:|---:|---:|\n")
|
|||
|
|
f.writelines([line + "\n" for line in format_table_rows(default_rows)])
|
|||
|
|
f.write("\n### 最差 8 组\n")
|
|||
|
|
f.write("| 排名 | Threshold | Cooldown | 即时命中率 | 平均等待 | P95等待 | 浪费率 | 播放成功率 |\n")
|
|||
|
|
f.write("|---|---:|---:|---:|---:|---:|---:|---:|\n")
|
|||
|
|
f.writelines([line + "\n" for line in format_table_rows(list(reversed(default_rows)), count=8)])
|
|||
|
|
|
|||
|
|
f.write("\n## 与纯手动模式对比(次留35%)\n")
|
|||
|
|
best_35 = best_retention_map[0.35]
|
|||
|
|
f.write("- 纯手动:")
|
|||
|
|
f.write(f"Immediate `{safe_ratio(default_baseline.immediate_play_count, default_baseline.total_show_requests):.2%}`,")
|
|||
|
|
f.write(f"AvgWait `{default_baseline.avg_wait_ms:.0f}`ms,Waste `{default_baseline.wasted_ratio:.2%}`。\n")
|
|||
|
|
f.write("- smart 最优:")
|
|||
|
|
f.write(f"阈值 `{best_35.threshold:.2f}`,cd `{best_35.cooldown_seconds}`,")
|
|||
|
|
f.write(f"Immediate `{safe_ratio(best_35.immediate_play_count, best_35.total_show_requests):.2%}`,")
|
|||
|
|
f.write(f"AvgWait `{best_35.avg_wait_ms:.0f}`ms,Waste `{best_35.wasted_ratio:.2%}`。\n")
|
|||
|
|
f.write(f"- 增益:`{safe_ratio(best_35.immediate_play_count, best_35.total_show_requests)-b_immediate:.2%}`。 \n\n")
|
|||
|
|
|
|||
|
|
f.write("### 网络分层对比(次留35%)\n")
|
|||
|
|
for idx, network_name in enumerate(NETWORK_LABELS):
|
|||
|
|
candidates = [r for r in results if r.retention == baseline_retention_default]
|
|||
|
|
best_net = max(
|
|||
|
|
candidates,
|
|||
|
|
key=lambda item: safe_ratio(item.network_immediate_show[idx], item.network_show_requests[idx]),
|
|||
|
|
)
|
|||
|
|
manual_immediate = safe_ratio(default_baseline.network_immediate_show[idx], default_baseline.network_show_requests[idx])
|
|||
|
|
manual_wait = default_baseline.network_avg_wait_ms[idx]
|
|||
|
|
smart_immediate = safe_ratio(best_net.network_immediate_show[idx], best_net.network_show_requests[idx])
|
|||
|
|
smart_wait = best_net.network_avg_wait_ms[idx]
|
|||
|
|
f.write(
|
|||
|
|
f"- {network_name.upper()}:手动即时 `{manual_immediate:.2%}` / 等待 `{manual_wait:.0f}ms`;"
|
|||
|
|
)
|
|||
|
|
f.write(
|
|||
|
|
f"智能(`threshold={best_net.threshold:.2f}, cd={best_net.cooldown_seconds}s`)即时 `{smart_immediate:.2%}` / 等待 `{smart_wait:.0f}ms`;"
|
|||
|
|
)
|
|||
|
|
f.write(f"提升 `{smart_immediate-manual_immediate:.2%}`。\n")
|
|||
|
|
|
|||
|
|
f.write("## 次留敏感度(35%基线)\n")
|
|||
|
|
for retention in sorted(ranked_sorted.keys()):
|
|||
|
|
best = ranked_sorted[retention][0]
|
|||
|
|
worst = ranked_sorted[retention][-1]
|
|||
|
|
b = baseline_by_retention[retention]
|
|||
|
|
f.write(
|
|||
|
|
f"- 次留 {retention:.0%}:手动基线时延 {b.avg_wait_ms:.0f}ms,智能最佳 `threshold={best.threshold:.2f}, cd={best.cooldown_seconds}s`,"
|
|||
|
|
)
|
|||
|
|
f.write(
|
|||
|
|
f"最佳 Immediate {safe_ratio(best.immediate_play_count, best.total_show_requests):.2%},Waste {best.wasted_ratio:.2%};"
|
|||
|
|
)
|
|||
|
|
f.write(
|
|||
|
|
f"最差 `threshold={worst.threshold:.2f}, cd={worst.cooldown_seconds}s`,"
|
|||
|
|
f"Immediate {safe_ratio(worst.immediate_play_count, worst.total_show_requests):.2%},Waste {worst.wasted_ratio:.2%}。\n"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
f.write("\n## 交叉参数观察\n")
|
|||
|
|
f.write("- 在同一保留率下,阈值下调能显著提高 `Immediate`,但通常也抬高 `Waste`。\n")
|
|||
|
|
f.write("- cooldown 拉长可降低 waste(减少重复预加载/空耗),但可能提高用户等待。\n")
|
|||
|
|
f.write("- 在弱网(尤其低分配机器人更多时)场景,建议提高 waste 上限约束后再考虑 lower threshold。\n\n")
|
|||
|
|
|
|||
|
|
f.write("## 图表示例(输出文件)\n")
|
|||
|
|
for p in sorted(out_dir.glob("*.png")):
|
|||
|
|
f.write(f"- `{p.name}`\n")
|
|||
|
|
f.write(f" \n\n")
|
|||
|
|
|
|||
|
|
f.write("## 验收结论\n")
|
|||
|
|
if balanced_default is not None:
|
|||
|
|
f.write(
|
|||
|
|
f"- 建议首轮灰度点:`threshold={balanced_default.threshold:.2f}`, `cooldown={balanced_default.cooldown_seconds}s`(兼顾即时性与 waste)。\n"
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
f.write(f"- 建议首轮灰度点:`threshold={best_default.threshold:.2f}`, `cooldown={best_default.cooldown_seconds}s`(当前样本未出现满足 12% Waste 约束的点)。\n")
|
|||
|
|
f.write(f"- 次留 35% 下智能策略最佳立即命中来自 `threshold={best_default.threshold:.2f}, cd={best_default.cooldown_seconds}s`,即时率 `{safe_ratio(best_default.immediate_play_count, best_default.total_show_requests):.2%}`,Waste `{best_default.wasted_ratio:.2%}`。\n")
|
|||
|
|
f.write("- 建议将 `smart_preload` 配置作为实验变量:先以纯手动为对照,再按次留分层和网络监控逐步放量。\n")
|
|||
|
|
|
|||
|
|
return report
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main() -> None:
|
|||
|
|
parser = argparse.ArgumentParser(description="TapADN SmartLoad sensitivity simulation")
|
|||
|
|
parser.add_argument("--users", type=int, default=3000)
|
|||
|
|
parser.add_argument("--policy-json", default=str(Path("Assets/Tapadn_Adapter/Runtime/Resources/TapadnSmartLoadPolicy_Default.json")))
|
|||
|
|
parser.add_argument("--out-dir", default="Tools/SmartLoadSensitivity/output")
|
|||
|
|
parser.add_argument("--robot-seed", type=int, default=20260604)
|
|||
|
|
parser.add_argument("--base-seed", type=int, default=1000)
|
|||
|
|
parser.add_argument("--include-line-mode", action="store_true")
|
|||
|
|
args = parser.parse_args()
|
|||
|
|
|
|||
|
|
policy_path = Path(args.policy_json)
|
|||
|
|
out_dir = Path(args.out_dir)
|
|||
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
profiles = [
|
|||
|
|
SceneProfile(
|
|||
|
|
ad_type=0,
|
|||
|
|
scene="reward",
|
|||
|
|
true_request_probability=0.42,
|
|||
|
|
fill_success_probability=0.86,
|
|||
|
|
mean_load_ms=2100.0,
|
|||
|
|
per_session_enter_probability=0.55,
|
|||
|
|
),
|
|||
|
|
SceneProfile(
|
|||
|
|
ad_type=2,
|
|||
|
|
scene="interstitial",
|
|||
|
|
true_request_probability=0.56,
|
|||
|
|
fill_success_probability=0.82,
|
|||
|
|
mean_load_ms=1750.0,
|
|||
|
|
per_session_enter_probability=0.72,
|
|||
|
|
),
|
|||
|
|
SceneProfile(
|
|||
|
|
ad_type=1,
|
|||
|
|
scene="splash",
|
|||
|
|
true_request_probability=0.96,
|
|||
|
|
fill_success_probability=0.78,
|
|||
|
|
mean_load_ms=1350.0,
|
|||
|
|
per_session_enter_probability=1.0,
|
|||
|
|
),
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
robots = generate_robot_profiles(args.users, args.robot_seed)
|
|||
|
|
policy_map = parse_policy_config(policy_path)
|
|||
|
|
threshold_grid = [round(0.20 + i * 0.05, 2) for i in range(0, 15)]
|
|||
|
|
cooldown_grid = [30, 60, 90, 120, 180, 240, 300]
|
|||
|
|
retention_rates = [0.20, 0.25, 0.30, 0.35, 0.40, 0.50, 0.60]
|
|||
|
|
|
|||
|
|
results, baseline = run_sensitivity(
|
|||
|
|
profiles=profiles,
|
|||
|
|
policy_map=policy_map,
|
|||
|
|
robots=robots,
|
|||
|
|
threshold_grid=threshold_grid,
|
|||
|
|
cooldown_grid=cooldown_grid,
|
|||
|
|
retention_rates=retention_rates,
|
|||
|
|
out_dir=out_dir,
|
|||
|
|
mode_manual_seed=args.base_seed,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
draw_heatmaps(results, 0.35, out_dir)
|
|||
|
|
draw_heatmaps(results, 0.20, out_dir)
|
|||
|
|
draw_heatmaps(results, 0.60, out_dir)
|
|||
|
|
draw_retention_lines(results, out_dir)
|
|||
|
|
report = build_report(results, baseline, out_dir, robot_count=len(robots))
|
|||
|
|
|
|||
|
|
print(f"[SmartLoadSimulation] generated outputs in: {out_dir}")
|
|||
|
|
print(f"[SmartLoadSimulation] report: {report}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
main()
|