import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import streamlit as st
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from dataclasses import dataclass
from scipy.stats import norm
# --- 1. DATA STRUCTURES ---
@dataclass
class Male:
strategy: str
target: float
phenotype: float
perceived_ideal: float
threshold: float
last_paired: int = -1
successes: int = 0
attempts_in_cycle: int = 0
paired_this_cycle: bool = False
@dataclass
class Female:
desire: float
attractiveness: float
paired_this_cycle: bool = False
# --- 2. HELPER FUNCTIONS ---
def calculate_gini(x):
"""Calculates the Gini coefficient for mating success inequality."""
x = np.array(x).flatten()
if len(x) == 0 or np.sum(x) == 0: return 0.0
x = np.sort(x)
n = len(x)
index = np.arange(1, n + 1)
return (np.sum((2 * index - n - 1) * x)) / (n * np.sum(x))
# --- 3. CORE LOGIC ---
def run_single_trial(params, ign_configs):
strats = [f"ign_{c['target']}" for c in ign_configs if c['count'] > 0] + ["calibrator"]
males = []
for cfg in ign_configs:
for _ in range(cfg["count"]):
males.append(Male(f"ign_{cfg['target']}", cfg['target'], np.clip(np.random.uniform(1, 10), 1, 10), 5.0, np.clip(np.random.normal(5, 1.5), 1, 10)))
for _ in range(params['cal_count']):
males.append(Male("calibrator", 0.0, np.clip(np.random.uniform(1, 10), 1, 10), 5.0, np.clip(np.random.normal(5, 1.5), 1, 10)))
total_males_init = len(males)
curr_market_mean = params['mean_fem_pref']
females = [Female(np.clip(np.random.normal(curr_market_mean, 1.5), 1, 10),
np.clip(np.random.normal(5, 2), 1, 10)) for _ in range(params['n_females'])]
history = {
'pop': {s: [] for s in strats},
'market': [], 'gini': [], 'selection': [], 'mse': [],
'cal_belief': [], 'cal_pheno': [],
'strat_success': {s: 0 for s in strats},
'cumulative_success': {s: [] for s in strats},
'pairing_latency': {s: [] for s in strats}
}
cycle_winners_phenos = []
total_cum_success = {s: 0 for s in strats}
for t in range(params['total_cycles']):
jump = np.random.lognormal(-2.5, params['jump_sigma']) * (1 if np.random.random() > 0.5 else -1)
curr_market_mean = np.clip(curr_market_mean + jump, 1.0, 10.0)
history['market'].append(curr_market_mean)
for f in females:
if not f.paired_this_cycle:
f.desire = np.clip(f.desire + jump, 1, 10)
active_males = [m for m in males if not m.paired_this_cycle]
active_females = [f for f in females if not f.paired_this_cycle]
np.random.shuffle(active_males)
np.random.shuffle(active_females)
current_round_winners = []
round_successes = {s: 0 for s in strats}
for i in range(min(len(active_males), len(active_females))):
m = active_males[i]; f = active_females[i]
m.attempts_in_cycle += 1
if abs(m.phenotype - f.desire) <= 1.0:
if not params['mutual_choice'] or (f.attractiveness >= m.threshold):
current_round_winners.append(m.phenotype)
if t > params['pairing_delay']:
m.paired_this_cycle = True; f.paired_this_cycle = True
cycle_winners_phenos.append(m.phenotype)
m.last_paired = t; m.successes += 1
history['strat_success'][m.strategy] += 1
total_cum_success[m.strategy] += 1
history['pairing_latency'][m.strategy].append(m.attempts_in_cycle)
signal_source = current_round_winners if current_round_winners else cycle_winners_phenos
market_signal = np.mean(signal_source) if signal_source else 5.0
for m in males:
target = m.perceived_ideal if m.strategy == "calibrator" else m.target
if m.strategy == "calibrator":
m.perceived_ideal += 0.3 * (market_signal - m.perceived_ideal)
m.phenotype = np.clip(m.phenotype + 0.2 * (target - m.phenotype), 1, 10)
cals = [m for m in males if m.strategy == "calibrator"]
history['cal_belief'].append(np.mean([m.perceived_ideal for m in cals]) if cals else np.nan)
history['cal_pheno'].append(np.mean([m.phenotype for m in cals]) if cals else np.nan)
history['mse'].append(np.mean([(m.phenotype - curr_market_mean)**2 for m in cals]) if cals else 0.0)
if t % params['regen_interval'] == 0:
for s in strats:
history['pop'][s].append(float(len([m for m in males if m.strategy == s])))
history['cumulative_success'][s].append(total_cum_success[s])
history['gini'].append(calculate_gini([m.successes for m in males]))
pop_avg = np.mean([m.phenotype for m in males])
win_avg = np.mean(cycle_winners_phenos) if cycle_winners_phenos else pop_avg
history['selection'].append(win_avg - pop_avg)
cycle_winners_phenos = []
for m in males:
m.paired_this_cycle = False; m.attempts_in_cycle = 0
females = [Female(np.clip(np.random.normal(curr_market_mean, 1.5), 1, 10),
np.clip(np.random.normal(5, 2), 1, 10)) for _ in range(params['n_females'])]
males = [m for m in males if m.last_paired == -1 or (t - m.last_paired < 40)]
while len(males) < total_males_init:
p = np.random.choice(males) if males else Male("calibrator", 5, 5, 5, 5)
males.append(Male(p.strategy, p.target, np.clip(np.random.uniform(1, 10), 1, 10), 5.0, np.clip(np.random.normal(5, 1.5), 1, 10)))
return history
# --- 4. UI HEADER ---
st.set_page_config(layout="wide", page_title="Evolutionary Mating Lab")
st.title("Evolutionary Mating Market Simulator -- Written by Patrick Liston")
with st.expander("π MASTER SIMULATION GUIDE", expanded=False):
st.markdown("""
# 𧬠Evolutionary Mating Lab: Master Guide
This dashboard models a competitive **mating market** where male agents compete for limited female pairings. The core tension lies between **Fixed Strategies** (specialization) and **Calibrators** (adaptation) in a non-stationary environment.
---
### βοΈ How the Simulation Works
The simulation uses temporal layers to mimic biological and social evolution:
1. **Rounds vs. Cycles**:
* **A Round** is a single discrete pairing attempt. If a pair forms, both agents are **immediately removed** from the pool for the remainder of that cycle (simulating relationship "opportunity cost").
* **A Cycle** (the **Regen Interval**) represents a reproductive window. At the end, all agents return to the pool. Males who haven't paired recently face **Mortality Risks**, simulating the evolutionary failure to pass on genes.
2. **Acceptance & Choice**:
* **Female Requirement**: The male's phenotype must fall within $\pm 1.0$ of the female's shifting preference.
* **Mutual Choice**: If enabled, males reject females who fall below their personal **Threshold**, creating "mismatch" scenarios even when the female is interested.
3. **The Adaptation Race**:
* **Calibrators**: These agents use a "Market Signal" (the average phenotype of successful males) to update their traits. They learn from the "Winners."
* **Fixed Strategies**: These agents (e.g., *ign_8.0*) never change. They succeed if the market drifts into their niche but face extinction if it moves away.
---
### π Measurements of Success
| Metric | Scientific Name | Layman's Meaning |
| :--- | :--- | :--- |
| **Gini Coefficient** | $G$ | **Inequality**. A high $G$ (near 0.6) means a minority of males account for most matings. |
| **Selection Pressure** | $S$ | **Pickiness**. How much "better" the winners are than the average population. |
| **RMSE** | Root Mean Square Error | **Learning Accuracy**. How far off Calibrators are from the true "Ideal." |
| **Mean Fitness ($W$)** | Relative Success | Average pairings per agent. A strategy needs $W \ge 1.0$ for stable survival. |
---
### π Parameter Quick-Reference
| Group | Parameter | Role |
| :--- | :--- | :--- |
| **Monte-Carlo** | Trials | Runs the "universe" multiple times to ensure results aren't just "luck." |
| **Female** | Drift Volatility ($\sigma$) | High values make the market chaotic; low values make it stable. |
| **Rules** | Regen Interval | The "Life Span." Short intervals punish slow learners; long intervals allow for recovery. |
| **Rules** | Pairing Delay | An initial window for agents to adapt phenotypes before mating begins. Post-delay, agents must pair to survive; those who fail "die", while successful strategies are "propagate" into the next generation.|
---
### π How to Use the Lab
1. **Configure the Market**: Set your **Females** and **Drift $\sigma$** in the sidebar.
2. **Define the Competition**: Set the counts for **Fixed Strategies** vs. **Calibrators**.
3. **Run Study**: Click the primary button. The simulation will run multiple "worlds" and average the results.
4. **Analyze Results**:
* **Persistence**: Check who survives.
* **Inequality**: Observe if the market becomes "Winner-Take-All."
* **Trial Inspector**: Zoom into a single "world" to see the "Red Queen" chase in real-time.
---
### π‘ Core Insight
**One-sentence takeaway:** This model demonstrates that once social learning, selection, and limited opportunities interact, **mating inequality becomes inevitable and self-reinforcing**βeven in a fluid, noisy market without hard-coded "alpha" rules.
**Other stuff to mention:** Alowing males to be choosy seemingly does not alter the main outcome, it just means it takes a little longer for agents to pair -- the end result is basically the same.
""")
# Sidebar
with st.sidebar:
st.header("π¬ Controls")
n_trials = st.slider("Trials", 1, 30, 5)
n_females = st.slider("Females", 100, 1000, 500)
mean_fem_pref = st.slider("Start Pref", 1.0, 10.0, 7.0)
jump_sigma = st.slider("Drift Ο", 0.1, 2.5, 1.0)
ign_configs = []
for i in range(1, 5):
with st.expander(f"Fixed Strategy {i}"):
t_input = st.number_input(f"Target {i}", 1.0, 10.0, float(i*2), key=f"t{i}")
c_input = st.number_input(f"Count {i}", 0, 500, 100, key=f"c{i}")
ign_configs.append({"target": t_input, "count": c_input})
cal_count = st.number_input("Calibrator Count", 0, 500, 100)
regen_interval = st.slider("Regen Interval", 5, 100, 25)
total_cycles = st.slider("Total Rounds", 100, 3000, 1000)
pairing_delay = st.slider("Pairing Delay", 0, 50, 25)
mutual_choice = st.checkbox("Mutual Choice")
if st.button("RUN MONTE-CARLO STUDY", type="primary"):
all_trials = []
total_males_init = sum(c['count'] for c in ign_configs) + cal_count
pb = st.progress(0); st_text = st.empty()
params = {'n_females': n_females, 'mean_fem_pref': mean_fem_pref, 'jump_sigma': jump_sigma,
'cal_count': cal_count, 'pairing_delay': pairing_delay, 'regen_interval': regen_interval,
'total_cycles': total_cycles, 'mutual_choice': mutual_choice, 'n_males': total_males_init}
for i in range(n_trials):
st_text.text(f"Running Trial {i+1}/{n_trials}...")
all_trials.append(run_single_trial(params, ign_configs))
pb.progress((i + 1) / n_trials)
st.session_state['all_trials'] = all_trials
st.session_state['params'] = params
if 'all_trials' in st.session_state:
all_trials = st.session_state['all_trials']
params = st.session_state['params']
strats = list(all_trials[0]['pop'].keys())
tabs = st.tabs(["Strategy Persistence", "Inequality & Selection", "Trial Inspector", "Initial Distributions", "Success Summary", "Learning Dynamics"])
with tabs[0]:
st.subheader("Monte-Carlo Persistence Analysis")
fig1, ax1 = plt.subplots(figsize=(10, 5))
fig2, ax2 = plt.subplots(figsize=(10, 5))
total_pop_matrix = np.zeros(len(all_trials[0]['pop'][strats[0]]))
for s in strats:
total_pop_matrix += np.mean([t['pop'][s] for t in all_trials], axis=0)
for s in strats:
data = np.array([t['pop'][s] for t in all_trials])
mu, std = np.mean(data, axis=0), np.std(data, axis=0)
ax1.plot(mu, label=s); ax1.fill_between(range(len(mu)), mu-std, mu+std, alpha=0.15)
pct_data = (data / (total_pop_matrix + 1e-9)) * 100
mu_p, std_p = np.mean(pct_data, axis=0), np.std(pct_data, axis=0)
ax2.plot(mu_p, label=f"{s} %"); ax2.fill_between(range(len(mu_p)), mu_p-std_p, mu_p+std_p, alpha=0.1)
ax1.set_title("Mean Strategy Population (MC)"); ax1.legend(); st.pyplot(fig1)
ax2.set_title("Strategy Market Share % (MC)"); ax2.legend(); st.pyplot(fig2)
with tabs[1]:
col1, col2 = st.columns(2)
with col1:
st.subheader("Market Gini Coefficient (G)")
st.latex(r"G = \frac{\sum_{i=1}^n \sum_{j=1}^n |x_i - x_j|}{2n^2\bar{x}}")
st.write("**Layman's Terms:** This measures 'fairness'. 0 means everyone mates equally; 1 means one person gets everything.")
st.write("**Formal Terms:** A measure of statistical dispersion representing the income or wealth inequality within a nation or social group.")
fig, ax = plt.subplots()
ax.plot(np.mean([t['gini'] for t in all_trials], axis=0), color='purple')
st.pyplot(fig)
st.info("**Interpretation:** A final G β 0.6 indicates a high-variance market where a minority of males account for most matings, resembling strong sexual selection.")
with col2:
st.subheader("Selection Pressure (S)")
st.latex(r"S = \bar{x}_{mated} - \bar{x}_{total}")
st.write("**Layman's Terms:** This is the 'Picky Factor'. It shows how much 'better' the winners are compared to the average guy.")
st.write("**Formal Terms:** The difference between the mean phenotype of the reproducing individuals and the mean phenotype of the entire population.")
fig, ax = plt.subplots()
ax.plot(np.mean([t['selection'] for t in all_trials], axis=0), color='red')
ax.axhline(0, color='black', ls='--'); st.pyplot(fig)
st.info("**Interpretation:** Positive values indicate the market is actively pulling the population toward higher phenotype values.")
with tabs[2]:
trial_idx = st.selectbox("Trial", range(len(all_trials)))
sel = all_trials[trial_idx]
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(sel['market'], color='green', label="Target")
ax.plot(sel['cal_belief'], color='red', ls='--', label="Belief")
ax.fill_between(range(len(sel['market'])), sel['market'], sel['cal_pheno'], color='gray', alpha=0.2)
ax.set_title(f"Trial #{trial_idx} Detail: Market Drift vs. Learning"); ax.legend(); st.pyplot(fig)
st.write(f"**Strategy Persistence - Trial #{trial_idx} Only**")
fig_tp, ax_tp = plt.subplots(figsize=(10, 3))
for s in strats:
ax_tp.plot(sel['pop'][s], label=s)
ax_tp.set_title(f"Population Count over Cycles (Trial #{trial_idx})"); ax_tp.legend(); st.pyplot(fig_tp)
fig_rmse, ax_rmse = plt.subplots(figsize=(10, 3))
ax_rmse.plot(np.sqrt(sel['mse']), color='brown', label="RMSE")
ax_rmse.set_title("Learning Accuracy (RMSE)"); ax_rmse.legend(); st.pyplot(fig_rmse)
with tabs[3]:
st.subheader("Initial Setup Statistics")
dist_col1, dist_col2 = st.columns(2)
with dist_col1:
fig_supply, ax_supply = plt.subplots()
# Market Supply Setup
thr_data = np.clip(np.random.normal(5, 1.5, 1000), 1, 10)
att_data = np.clip(np.random.normal(5, 2.0, 1000), 1, 10)
sns.histplot(thr_data, color="blue", label="Male Thresholds", kde=True, stat="density", alpha=0.3, ax=ax_supply)
sns.histplot(att_data, color="pink", label="Female Attractiveness", kde=True, stat="density", alpha=0.3, ax=ax_supply)
ax_supply.set_title("Market Demand (Threshold) vs. Supply (Attractiveness)")
ax_supply.set_xlim(1, 10); ax_supply.legend(); st.pyplot(fig_supply)
st.write("This distribution compares the pickiness of males (Thresholds) against the quality of females (Attractiveness).")
with dist_col2:
fig_demand, ax_demand = plt.subplots()
# Market Demand Setup
mus_data = np.clip(np.random.uniform(1, 5, 1000), 1, 10)
pre_data = np.clip(np.random.normal(params['mean_fem_pref'], 1.5, 1000), 1, 10)
sns.histplot(mus_data, color="cyan", label="Initial Male Phenotype", kde=True, stat="density", alpha=0.3, ax=ax_demand)
sns.histplot(pre_data, color="orange", label="Initial Female Preference", kde=True, stat="density", alpha=0.3, ax=ax_demand)
ax_demand.set_title("Initial Trait Supply vs. Desire")
ax_demand.set_xlim(1, 10); ax_demand.legend(); st.pyplot(fig_demand)
st.write("This compares the starting traits of males against the initial aggregate desire of females.")
with tabs[4]:
st.subheader("Strategy Performance Rankings")
summary = []
for s in strats:
avg_pop_over_time = np.mean([t['pop'][s] for t in all_trials])
avg_final_pop = np.mean([t['pop'][s][-1] for t in all_trials])
total_s = np.sum([t['strat_success'][s] for t in all_trials])
latency = np.mean([np.mean(t['pairing_latency'][s]) if t['pairing_latency'][s] else np.nan for t in all_trials])
summary.append({
"Strategy": s,
"Mean Fitness (W)": round(total_s/(avg_pop_over_time * params['total_cycles']+1), 4),
"Avg Rounds to Pair": round(latency, 2),
"Avg Final Pop": round(avg_final_pop, 2)
})
st.table(pd.DataFrame(summary).sort_values("Mean Fitness (W)", ascending=False))
with tabs[5]:
c1, c2 = st.columns(2)
with c1:
fig, ax = plt.subplots()
for s in strats:
ax.plot(np.mean([t['cumulative_success'][s] for t in all_trials], axis=0), label=s)
ax.set_title("Wealth (Cumulative Success)"); ax.legend(); st.pyplot(fig)
with c2:
st.subheader("Risk Analysis")
vols = [{"Strategy": s, "Volatility (SD)": round(np.mean([np.std(t['pop'][s]) for t in all_trials]), 2)} for s in strats]
st.table(pd.DataFrame(vols))
# --- 5. DATA EXPORT (Consolidated for PyCafe) ---
import io
import zipfile
import base64
if 'all_trials' in st.session_state:
st.divider()
st.subheader("π₯ Export Simulation Data")
# 1. Create the ZIP in memory
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
# Save Parameters
param_df = pd.DataFrame([st.session_state['params']])
zip_file.writestr("simulation_parameters.csv", param_df.to_csv(index=False))
# Save detailed time-series for each trial
for idx, trial in enumerate(st.session_state['all_trials']):
ts_data = {
'Cycle': range(len(trial['market'])),
'Market_Target': trial['market'],
'Gini_Coefficient': trial['gini'] + [np.nan] * (len(trial['market']) - len(trial['gini'])),
'Selection_Pressure': trial['selection'] + [np.nan] * (len(trial['market']) - len(trial['selection'])),
'Calibrator_MSE': trial['mse']
}
# Add population counts for each strategy
for strat, pops in trial['pop'].items():
ts_data[f'Pop_{strat}'] = pops + [np.nan] * (len(trial['market']) - len(pops))
zip_file.writestr(f"trial_{idx}_timeseries.csv", pd.DataFrame(ts_data).to_csv(index=False))
# 2. Encode to Base64 (This turns the binary file into a text string the browser can read)
b64 = base64.b64encode(zip_buffer.getvalue()).decode()
# 3. Create a Custom HTML "Safe" Download Link
# This creates a button-style link that forces the browser to handle the data as a file
href = f'''
<a href="data:application/zip;base64,{b64}"
download="mating_market_results.zip"
style="text-decoration: none; background-color: #ff4b4b; color: white; padding: 10px 24px; border-radius: 8px; font-weight: bold; display: inline-block;">
Download All Trials as ZIP
</a>
'''
st.markdown(href, unsafe_allow_html=True)
st.caption("Note: This link uses a 'Browser Blob' to bypass security blocks found on some free hosts.")