Spaces:
Runtime error
Runtime error
Download make_report.py from stevafernandes/bee-ai: direct link, hf CLI and curl.
- Browser
- Download file 22 kB
-
https://huggingface.co/spaces/stevafernandes/bee-ai/resolve/main/make_report.py
- Command line
-
hf download hf://spaces/stevafernandes/bee-ai/make_report.py
-
curl -L -o make_report.py https://huggingface.co/spaces/stevafernandes/bee-ai/resolve/main/make_report.py
22 kB
| """ | |
| make_report.py - assemble REPORT.md from the artifacts (nothing in the report is typed by hand). | |
| Inputs: artifacts/results/results.json, permutation.json (inside results.json), survival.json, | |
| artifacts/features/qc.csv, gemini_semantic.csv, gemini_retest.csv, | |
| artifacts/literature/verified_claims.json, models/config.json (optional) | |
| Outputs: REPORT.md, artifacts/results/gemini_reliability.json, artifacts/results/qc_by_batch.csv | |
| """ | |
| import json | |
| import os | |
| import sys | |
| import numpy as np | |
| import pandas as pd | |
| HERE = os.path.dirname(os.path.abspath(__file__)) | |
| sys.path.insert(0, HERE) | |
| import data as bd # noqa: E402 | |
| RES = os.path.join(HERE, "artifacts", "results") | |
| FEAT = os.path.join(HERE, "artifacts", "features") | |
| LIT = os.path.join(HERE, "artifacts", "literature", "verified_claims.json") | |
| def fmt_ci(v, ci, signed=False): | |
| f = "{:+.2f}" if signed else "{:.2f}" | |
| return f"{f.format(v)} [{f.format(ci[0])}, {f.format(ci[1])}]" | |
| def gemini_reliability(df): | |
| p1, p2 = os.path.join(FEAT, "gemini_semantic.csv"), os.path.join(FEAT, "gemini_retest.csv") | |
| if not (os.path.exists(p1) and os.path.exists(p2)): | |
| return None | |
| a = pd.read_csv(p1).set_index("file"); b = pd.read_csv(p2).set_index("file") | |
| common = a.index.intersection(b.index) | |
| a, b = a.loc[common], b.loc[common] | |
| out = dict(n=int(len(common))) | |
| for col, key in (("gem_is_bee", "agree_is_bee"), ("gem_activity", "agree_activity"), ("gem_quality", "agree_quality")): | |
| m = a[col].notna() & b[col].notna() | |
| out[key] = float((a.loc[m, col] == b.loc[m, col]).mean()) if m.any() else None | |
| # chance-corrected agreement (Cohen's kappa) | |
| if m.any(): | |
| x, y = a.loc[m, col].astype(int), b.loc[m, col].astype(int) | |
| po = float((x == y).mean()) | |
| cats = sorted(set(x) | set(y)) | |
| pe = float(sum((x == c).mean() * (y == c).mean() for c in cats)) | |
| out[key.replace("agree", "kappa")] = float((po - pe) / (1 - pe)) if pe < 1 else None | |
| out["is_bee_rate_pass1"] = float(a["gem_is_bee"].mean()) | |
| out["is_bee_rate_pass2"] = float(b["gem_is_bee"].mean()) | |
| out["mean_abs_conf_diff"] = float((a["gem_confidence"] - b["gem_confidence"]).abs().mean()) | |
| # by site | |
| meta = df.set_index(df["sound_file"].str.lower()) | |
| out["is_bee_rate_by_country"] = {c: float(a.loc[[f for f in common if meta.loc[f, "country"] == c], "gem_is_bee"].mean()) | |
| for c in sorted(set(meta["country"]))} | |
| if "gem_notes" in a.columns: | |
| out["examples_disagreeing"] = [dict(file=f, pass1=str(a.loc[f, "gem_dominant_sounds"]), pass2=None) | |
| for f in list(common[(a["gem_is_bee"] != b["gem_is_bee"]).values])[:5]] | |
| return out | |
| def qc_by_batch(df): | |
| p = os.path.join(FEAT, "qc.csv") | |
| if not os.path.exists(p): | |
| return None | |
| q = pd.read_csv(p); q["file"] = q["file"].str.lower() | |
| m = df.assign(file=df["sound_file"].str.lower()).merge(q, on="file") | |
| g = m.groupby("recording_batch")[["duration_s", "rms_db", "noise_floor_db", "level_range_db", "clip_frac"]].median().round(2) | |
| g["n"] = m.groupby("recording_batch").size() | |
| g["n_terminal_46"] = m.groupby("recording_batch")["y46"].apply(lambda s: int((s == "T").sum())) | |
| g.to_csv(os.path.join(RES, "qc_by_batch.csv")) | |
| return g | |
| def main(): | |
| df = bd.load_workbook(os.path.join(HERE, "AI_Data_Training.xlsx"), audio_dir=None) | |
| R = json.load(open(os.path.join(RES, "results.json"))) | |
| meta, res = R["meta"], R["results"] | |
| S = json.load(open(os.path.join(RES, "survival.json"))) if os.path.exists(os.path.join(RES, "survival.json")) else None | |
| lit = json.load(open(LIT)) if os.path.exists(LIT) else [] | |
| rel = gemini_reliability(df) | |
| if rel: | |
| json.dump(rel, open(os.path.join(RES, "gemini_reliability.json"), "w"), indent=1) | |
| qb = qc_by_batch(df) | |
| L = [] | |
| L.append("# What hive sound can and cannot predict: colony survival, viral loads and the missing Varroa data") | |
| L.append("") | |
| L.append("Generated by make_report.py from the pipeline artifacts. Every number below is computed; none is typed in.") | |
| L.append("") | |
| # ------------------------------------------------------------------ data | |
| n_rows = len(df); n_col = df["colony"].nunique() | |
| L.append("## 1. Data") | |
| L.append("") | |
| L.append(f"- Workbook rows: {n_rows}; recordings with an audio file: {meta['n_recordings']}; colonies: {n_col} " | |
| f"(USA {df[df.country=='USA'].colony.nunique()}, NZ {df[df.country=='NZ'].colony.nunique()}).") | |
| L.append(f"- Measured quantities per recording month: {', '.join(c for c in df.columns if c in ('CBPV','DWV','KBV'))} (viral loads) and the two survival labels. " | |
| "There is no Varroa mite count, colony size, queen status, brood, weight or nutrition column in the workbook, so none of those can be modelled or tested here.") | |
| pos = df.groupby("country")[["CBPV", "DWV", "KBV"]].agg(lambda s: (s > 0).mean()) | |
| L.append("- Fraction of recordings with a detectable load, by site: " + "; ".join( | |
| f"{c}: CBPV {pos.loc[c,'CBPV']:.2f}, DWV {pos.loc[c,'DWV']:.2f}, KBV {pos.loc[c,'KBV']:.2f}" for c in pos.index) + ".") | |
| t46 = df[df.y46 == "T"]; t03 = df[df.y03 == "T"] | |
| L.append(f"- Terminal labels: {len(t03)} recordings (0-3 months) and {len(t46)} recordings (4-6 months) from " | |
| f"{df[(df.y03=='T')|(df.y46=='T')].colony.nunique()} colonies; 12 of those colonies (F1-F12) were recorded once, in October 2023, and are all Terminal.") | |
| L.append("- Label structure: within a colony the labels move monotonically S/S -> S/T -> T/T towards the month of death, i.e. the labels encode months-to-death.") | |
| L.append("") | |
| # ------------------------------------------------------------------ method | |
| L.append("## 2. Method") | |
| L.append("") | |
| L.append(f"- Features per recording: handcrafted acoustic features ({meta['families'].get('hc','-')}), AST embedding ({meta['families'].get('ast','-')}: mean and SD of 768-d window embeddings), " | |
| f"CLAP embedding ({meta['families'].get('clap','-')}), Gemini structured listening ({meta['families'].get('gem','-')}), recording QC ({meta['families'].get('qc','-')}), metadata ({', '.join(meta.get('metadata_columns') or [])}).") | |
| L.append(f"- Validation: {meta['n_splits']}-fold colony-grouped cross-validation repeated {meta['n_repeats']} times (all recordings of a colony on one side), " | |
| f"metrics on out-of-fold predictions; colony-cluster bootstrap ({meta['n_boot']} resamples) 95% CI; month-aligned colony permutation test ({meta['n_perm']} permutations: " | |
| "colony identities permuted within country and each recording given the donor colony's label for the same calendar month, so site, season, batch and within-colony label structure are preserved and p-values test for acoustic signal beyond them).") | |
| L.append(f"- Models fixed in advance: PCA({meta['pca_k']}) + L2 logistic / ridge for embeddings, gradient boosting for handcrafted features, rank-average ensemble as the primary model. No per-target model selection.") | |
| L.append(f"- Confound controls: metadata-only baseline and paired bootstrap of the gain of audio over it; within-country evaluation with its own metadata baseline; cross-site transfer; every target re-run without the October 2023 batch; probes that ask whether audio can identify site, season and the October 2023 batch. Evaluations need at least {meta.get('min_pos_colonies', 8)} positive and {meta.get('min_pos_colonies', 8)} negative colonies.") | |
| L.append("- Verdict rule (fixed before the run): level 0 = the primary CI includes chance (the upper bound is stated); level 1 = CI excludes chance but the permutation p is at least 0.05 or the paired gain over metadata is not positive; level 2 = CI excludes chance, permutation p below 0.05 and paired gain over metadata positive. Holm-adjusted p-values across the non-probe targets are shown in brackets.") | |
| L.append("") | |
| # ------------------------------------------------------------------ results table | |
| L.append("## 3. Which targets are predictable from sound") | |
| L.append("") | |
| L.append("Metric: ROC-AUC (chance 0.50) for binary targets, Spearman correlation (chance 0) for levels; primary model = ensemble. " | |
| "'Gain' is the paired-bootstrap difference between metadata+audio and metadata alone. Verdicts follow the pre-registered rule in evaluate.py.") | |
| L.append("") | |
| L.append("| target | n (pos) [positive colonies] | metadata only | ensemble | gain over metadata | within-country gain of audio over metadata | perm. p (Holm) | without Oct 2023 batch (ensemble) | verdict |") | |
| L.append("|---|---|---|---|---|---|---|---|---|") | |
| order = sorted(res, key=lambda k: (res[k]["verdict"]["level"] * -1, k)) | |
| for name in order: | |
| r = res[name] | |
| if "families" not in r: | |
| L.append(f"| {name} | {r.get('n','')} | | | | | | {r['verdict']['label']} ({r['verdict'].get('reason','')}) |") | |
| continue | |
| key = "auc" if r["kind"] == "binary" else "spearman" | |
| f = r["families"] | |
| def c(k): | |
| return fmt_ci(f[k][key], f[k]["ci"], signed=(key == "spearman")) if k in f else "-" | |
| d = r.get("delta_over_meta", {}).get("meta+all_audio") | |
| gain = f"{d['mean']:+.2f} [{d['lo']:+.2f}, {d['hi']:+.2f}]" if d else "-" | |
| wcs = [] | |
| for cn, wc in (r.get("within_country") or {}).items(): | |
| if "skipped" in wc: | |
| wcs.append(f"{cn}: {wc['skipped'].replace('not estimable: ', '')}") | |
| else: | |
| dd = (wc.get("delta_over_meta") or {}).get("meta+all_audio") | |
| au = wc["families"].get("all_audio") | |
| wcs.append(f"{cn}: audio {fmt_ci(au[key], au['ci'], signed=(key=='spearman'))}" + (f", gain {dd['mean']:+.2f} [{dd['lo']:+.2f}, {dd['hi']:+.2f}]" if dd else "")) | |
| within = "; ".join(wcs) or "-" | |
| v = r["verdict"] | |
| p = v.get("p_perm"); ph = v.get("p_perm_holm") | |
| ps = "-" if p is None else f"{p:.3f}" + (f" ({ph:.3f})" if ph is not None else "") | |
| wo = v.get("without_oct2023") | |
| wos = fmt_ci(wo["value"], wo["ci"], signed=(key == "spearman")) if wo else "-" | |
| npos = f"{r['n']} ({r.get('n_pos','')}) [{r.get('n_pos_colonies','')}]" if r["kind"] == "binary" else str(r["n"]) | |
| lab = v["label"] + (f"; {v['upper_bound']}" if v.get("upper_bound") else "") | |
| L.append(f"| {name} | {npos} | {c('meta')} | {c('ensemble')} | {gain} | {within} | {ps} | {wos} | {lab} |") | |
| L.append("") | |
| # per-family detail | |
| L.append("### 3a. Per-family detail (point estimate [95% CI])") | |
| L.append("") | |
| fams_all = [k for k in ("meta", "qc", "gem", "hc", "ast", "clap", "ast+hc", "all_audio", "meta+all_audio", "lab+meta", "lab+meta+all_audio", "ensemble")] | |
| present = [k for k in fams_all if any(k in r.get("families", {}) for r in res.values())] | |
| L.append("| target | " + " | ".join(present) + " |") | |
| L.append("|---|" + "---|" * len(present)) | |
| for name in order: | |
| r = res[name] | |
| if "families" not in r: | |
| continue | |
| key = "auc" if r["kind"] == "binary" else "spearman" | |
| cells = [fmt_ci(r["families"][k][key], r["families"][k]["ci"], signed=(key == "spearman")) if k in r["families"] else "-" for k in present] | |
| L.append(f"| {name} | " + " | ".join(cells) + " |") | |
| L.append("") | |
| # transfer + no-oct | |
| L.append("### 3b. Cross-site transfer and the October 2023 batch") | |
| L.append("") | |
| L.append("| target | family | USA->NZ | NZ->USA | without Oct 2023 batch (ensemble members) |") | |
| L.append("|---|---|---|---|---|") | |
| for name in order: | |
| r = res[name] | |
| tr = r.get("transfer", {}) | |
| key = "auc" if r["kind"] == "binary" else "spearman" | |
| for famk in ("all_audio",): | |
| t = tr.get(famk, {}) | |
| def tc(k): | |
| v = t.get(k); return fmt_ci(v[key], v["ci"], signed=(key == "spearman")) if v else "-" | |
| wo = r.get("without_oct2023", {}).get("families", {}) | |
| wos = "; ".join(f"{k}: {fmt_ci(v[key], v['ci'], signed=(key=='spearman'))}" for k, v in wo.items() if k in ("meta", "all_audio", "ensemble")) or "-" | |
| if t or wo: | |
| L.append(f"| {name} | {famk} | {tc('USA->NZ')} | {tc('NZ->USA')} | {wos} |") | |
| L.append("") | |
| # ------------------------------------------------------------------ probes | |
| L.append("## 4. What the audio actually encodes: site, season and recording conditions") | |
| L.append("") | |
| for name in ("probe_country_is_nz", "probe_batch_oct2023", "probe_month_sin", "probe_month_cos"): | |
| r = res.get(name) | |
| if r and "families" in r: | |
| key = "auc" if r["kind"] == "binary" else "spearman" | |
| best = max((k for k in ("hc", "ast", "clap", "all_audio") if k in r["families"]), key=lambda k: r["families"][k][key]) | |
| L.append(f"- {r['description']}: best audio family {best} {fmt_ci(r['families'][best][key], r['families'][best]['ci'], signed=(key=='spearman'))}.") | |
| if qb is not None: | |
| L.append("") | |
| L.append("Recording-quality statistics by batch (median per site-year-month; full table in artifacts/results/qc_by_batch.csv):") | |
| L.append("") | |
| L.append("| batch | n | Terminal(4-6) rows | duration s | RMS dB | noise floor dB | level range dB |") | |
| L.append("|---|---|---|---|---|---|---|") | |
| for b, row in qb.iterrows(): | |
| L.append(f"| {b} | {int(row['n'])} | {int(row['n_terminal_46'])} | {row['duration_s']:.1f} | {row['rms_db']:.1f} | {row['noise_floor_db']:.1f} | {row['level_range_db']:.1f} |") | |
| L.append("") | |
| # ------------------------------------------------------------------ gemini | |
| L.append("## 5. Gemini") | |
| L.append("") | |
| gm = [k for k in res if "gem" in res[k].get("families", {})] | |
| if rel: | |
| L.append(f"- Structured listening (model {os.environ.get('GEMINI_MODELS', 'gemini-3.8-flash').split(',')[0]}, 30 s excerpt, temperature 0) was run twice on {rel['n']} recordings. " | |
| f"Agreement between the two passes: 'is honeybee colony audio' {100*rel['agree_is_bee']:.0f}% (kappa {rel.get('kappa_is_bee', float('nan')):.2f}), " | |
| f"activity level {100*rel['agree_activity']:.0f}% (kappa {rel.get('kappa_activity', float('nan')):.2f}), recording quality {100*rel['agree_quality']:.0f}% (kappa {rel.get('kappa_quality', float('nan')):.2f}). " | |
| f"Share of recordings called honeybee audio: pass 1 {100*rel['is_bee_rate_pass1']:.0f}%, pass 2 {100*rel['is_bee_rate_pass2']:.0f}%; by site: " | |
| + ", ".join(f"{c} {100*v:.0f}%" for c, v in rel["is_bee_rate_by_country"].items()) + ".") | |
| if gm: | |
| L.append("- Predictive value of the Gemini listening features (family 'gem', linear model, colony-grouped CV):") | |
| for name in order: | |
| r = res[name] | |
| if "gem" in r.get("families", {}): | |
| key = "auc" if r["kind"] == "binary" else "spearman" | |
| L.append(f" - {name}: {fmt_ci(r['families']['gem'][key], r['families']['gem']['ci'], signed=(key=='spearman'))}") | |
| L.append("- The app also lets Gemini write a summary, but only from a JSON evidence object of computed numbers; the numbers are displayed next to the text so every statement can be checked.") | |
| L.append("") | |
| # ------------------------------------------------------------------ survival | |
| if S: | |
| L.append("## 6. Factors associated with colony death (observational)") | |
| L.append("") | |
| L.append("Full tables: artifacts/results/survival_report.md. Key estimates (odds ratio per unit log(1+load) or per presence, 95% CI):") | |
| L.append("") | |
| for g in S["gee"]: | |
| if g["outcome"] == "y46" and g.get("terms") and all(np.isfinite(v["odds_ratio"]) for v in g["terms"].values()): | |
| L.append(f"- GEE, {g['label']} (n={g['n']}, events={g['n_events']} rows): " + "; ".join( | |
| f"{k} OR {v['odds_ratio']:.2f} [{v['ci'][0]:.2f}, {v['ci'][1]:.2f}]" for k, v in g["terms"].items() if not k.startswith("month")) + ".") | |
| for g in S.get("firth", []): | |
| if g["outcome"] == "y46" and g["label"].startswith("PRIMARY"): | |
| L.append(f"- Firth (Wald CI), {g['label']} (n={g['n']}, event colonies={g['n_event_colonies']}, separation={g['separation_detected']}): " + "; ".join( | |
| (f"{k} OR {v['odds_ratio']:.2f} [{v['ci'][0]:.2f}, {v['ci'][1]:.2f}]" if v.get("estimable", True) else f"{k} not estimable") | |
| for k, v in g["terms"].items() if not k.startswith("month")) + ".") | |
| ptc = S.get("positive_terminal_counts", {}) | |
| if ptc: | |
| L.append("- Identifiability (Terminal colonies positive / Terminal colonies, outcome y46): " + "; ".join( | |
| f"{k.split('|')[0]} {k.split('|')[2]} {v['terminal_colonies_positive']}/{v['terminal_colonies']}" for k, v in ptc.items() if k.split("|")[1] == "y46") + ".") | |
| ct = S["colony_tests"] | |
| L.append("- Colony level (died vs survived): " + "; ".join( | |
| f"{c} ever positive {t['ever_positive_died']} vs {t['ever_positive_survived']} (Fisher p {t['fisher_p']:.3f})" for c, t in ct.items() if c not in ("n_recordings", "well_monitored")) + ".") | |
| avl = S.get("audio_vs_lab") | |
| if avl: | |
| for t, r in avl.items(): | |
| d = r.get("delta_audio_over_lab_meta") | |
| if d and r.get("lab+meta") and r.get("lab+meta+all_audio"): | |
| L.append(f"- {t}: laboratory+metadata AUC {fmt_ci(r['lab+meta']['auc'], r['lab+meta']['ci'])}; adding audio {fmt_ci(r['lab+meta+all_audio']['auc'], r['lab+meta+all_audio']['ci'])}; gain {d['mean']:+.3f} [{d['lo']:+.3f}, {d['hi']:+.3f}].") | |
| L.append("") | |
| L.append("Interpretation: the twelve October 2023 colonies were sampled because they were failing (selection on the outcome), and they supply " | |
| "every DWV-positive Terminal row in the USA. The primary specification therefore excludes them; read the all-recordings odds ratios as a sensitivity run only. " | |
| "Varroa, the established vector of DWV, is unmeasured, so the causal pathway Varroa -> DWV -> death cannot be tested with this workbook.") | |
| L.append("") | |
| # ------------------------------------------------------------------ recommendations | |
| L.append("## 7. What to collect next") | |
| L.append("") | |
| L.append("1. Varroa mite counts (alcohol wash or sugar roll per 300 bees) at every recording visit. Varroa is the strongest documented predictor of colony loss and the vector of DWV; it is absent from the workbook, and the only acoustic-Varroa study with real scale (Yildiz and Karabag 2026) does not state whether its split was hive-independent.") | |
| L.append("2. Colony strength at each visit (frames of bees, brood area, adult population estimate) and queen status. These are the colony states with peer-reviewed evidence of acoustic detectability (queen loss, swarming) and the most plausible mediators of any sound-survival link.") | |
| L.append("3. A recording protocol that fixes the confounds seen here: same recorder and gain at both sites, microphone position inside the hive, time of day and ambient temperature logged, and a short silence sample per visit. The audio currently identifies site and season far better than any pathogen, so recording conditions must be equalised before acoustic pathogen claims are possible.") | |
| L.append("4. Pathogen sampling with matched timing and units: the workbook mixes zero-inflated loads whose units are not documented; record the assay, the unit and the detection limit, and sample the same bees that were recorded.") | |
| L.append("5. Longer follow-up of the same colonies (outcome at 12 months) and more dying colonies with monthly series; the current 12 monitored deaths are too few for a reliable survival model of any kind.") | |
| L.append("") | |
| # ------------------------------------------------------------------ literature | |
| if lit: | |
| L.append("## 8. Verified references") | |
| L.append("") | |
| L.append("Each entry was located and read by an agent and then independently re-checked against the source by a second agent; only entries that survived that check are listed. Claims are stated as the checker rewrote them.") | |
| L.append("") | |
| for topic in lit: | |
| L.append(f"### {topic['topic']}") | |
| L.append("") | |
| for cl in topic["verified"]: | |
| v = cl["verdict"] | |
| if v.get("supported"): | |
| L.append(f"- {v.get('corrected_claim') or cl['claim']} ") | |
| L.append(f" {v.get('corrected_citation') or cl['citation']}. {cl['url']}") | |
| notes = topic.get("notes", "").strip() | |
| if notes: | |
| L.append("") | |
| L.append(f"Search notes (what could not be found or verified): {notes}") | |
| L.append("") | |
| # ------------------------------------------------------------------ limitations | |
| L.append("## 9. Limitations") | |
| L.append("") | |
| L.append("- Observational data; two sites with different virus profiles, recorders and seasons; 12 of 24 deaths come from one single-visit batch.") | |
| L.append("- Viral load units and assay detection limits are not documented in the workbook.") | |
| L.append("- Gemini listening is a large language model's impression of a 30 s excerpt; its repeatability is reported above and it must not be read as a measurement.") | |
| L.append("- The deployed models are refit on all recordings; their expected performance on new colonies is the colony-grouped estimate in section 3, not their fit on the training recordings.") | |
| md = "\n".join(L) | |
| open(os.path.join(HERE, "REPORT.md"), "w").write(md) | |
| print(md[:3000]) | |
| print("...\nwrote REPORT.md") | |
| if __name__ == "__main__": | |
| main() | |