Publikationsbias

Zeit: 15 min | Schwierigkeit: Einsteiger

Theorie: Die Schubladen voller Fehlschläge

Die Wissenschaft hat ein systemisches Problem mit der Veröffentlichung von Ergebnissen. Niemand muss aktiv lügen oder manipulieren, dennoch entsteht ein massiv verzerrtes Bild der Realität.

Der Mechanismus ist einfach: Forscher reichen signifikante Ergebnisse nahezu immer zur Publikation ein, weil sie publizierbar sind. Nicht-signifikante Ergebnisse landen dagegen häufiger in der Schublade. Dies geschieht nicht aus Böswilligkeit, sondern weil Fachzeitschriften sie ohnehin seltener annehmen und Forschende daher oft gar nicht erst die Mühe des Einreichens auf sich nehmen. Das Ergebnis ist eine wissenschaftliche Literatur, die systematisch zu positiv ist.

Wenn Forschende später den Durchschnitt aller publizierten Studien berechnen (z. B. im Rahmen einer Metaanalyse), überschätzen sie den wahren Effekt, weil die zahlreichen Null-Ergebnisse schlicht fehlen. Kein Betrug, kein Vorsatz. Nur ein System, das die falsche Frage belohnt: “Ist es signifikant?” statt “Wie groß ist der Effekt wirklich?”

Beispiel SmartRail: 40 unabhängige Tests, ein verzerrtes Bild

Unabhängige Forschungsteams testen SmartRail auf 40 weiteren Strecken. Das System hat dort tatsächlich eine echte, aber kleine Wirkung: Der wahre mittlere Zeitgewinn beträgt 2 Minuten pro Fahrt. Weil die Teams unterschiedlich große Stichproben erheben und auf unterschiedlich störungsanfälligen Strecken messen, variiert die Präzision ihrer Schätzungen stark. Gut ausgestattete Teams messen den Effekt zuverlässig und signifikant. Schlechter ausgestattete Teams erhalten zwar im Durchschnitt ebenfalls rund 2 Minuten, verfehlen aber die Signifikanzschwelle, weil ihre Stichproben zu klein oder ihre Streuung zu groß ist.

Teams mit signifikantem Ergebnis reichen sofort ein. Teams ohne signifikantes Ergebnis zweifeln: “Haben wir etwas falsch gemacht? Lohnt sich der Aufwand der Einreichung?” Viele legen die Daten in die Schublade.

Die Folge: In die Literatur und damit in jede spätere Metaanalyse fließen vor allem die präziseren Studien mit den höheren gemessenen Effekten ein. Der metaanalytische Durchschnitt der publizierten Studien liegt damit über dem wahren Effekt von 2 Minuten. Das System wirkt, aber die Literatur zeichnet ein zu positives Bild davon. Niemand hat gelogen.

Deine Aufgabe

Die Applikation zeigt alle 40 Testergebnisse als Balkenchart. Du steuerst, wie stark das Einreichungsverhalten das Bild verzerrt.

  1. Ausgangslage: Bei 0 % Einreichungslücke siehst du alle 40 Studien. Der metaanalytische Durchschnitt (farbige Linie) liegt nahe am wahren Effekt von 2 Minuten (gestrichelte Linie). Kein Bias.
  2. Einreichungslücke erhöhen: Erhöhe den Regler schrittweise. Nicht-signifikante Studien verschwinden aus der Literatur, nicht weil jemand sie versteckt, sondern weil sie nie eingereicht wurden. Beobachte, wie der metaanalytische Durchschnitt nach rechts driftet.
  3. Maximaler Bias: Nur noch die signifikantesten Studien sind sichtbar. Die Metaanalyse schätzt den Effekt von SmartRail höher ein als er tatsächlich ist. Niemand hat gelogen, aber das Bild ist verzerrt.

⏳ Die Anwendung wird geladen, dies kann bis zu 30 Sekunden dauern.

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| viewerHeight: 740

from shiny import App, render, ui
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.lines import Line2D

np.random.seed(42)
N           = 40
TRUE_EFFECT = 2.0                               # echter, aber kleiner Effekt in Minuten
se_vals     = np.random.uniform(0.4, 2.0, N)
effects     = np.random.normal(TRUE_EFFECT, se_vals, N)
is_sig_pos  = (effects / se_vals) > 1.96       # einseitig signifikant positiv
order       = np.argsort(effects)              # einmal sortieren, stabil

app_ui = ui.page_fluid(
    ui.card(
        ui.card_header("SmartRail: Publikationsbias — kein Betrug, nur ein fehlgeleitetes System"),
        ui.layout_columns(
            ui.div(
                ui.h5("Einreichungsverhalten einstellen"),
                ui.input_slider(
                    "bias_pct",
                    "Anteil nicht-signifikanter Studien, die nie eingereicht wurden",
                    min=0, max=100, value=0, step=10,
                    post="%"
                ),
                ui.hr(),
                ui.output_ui("bias_panel"),
            ),
            ui.output_plot("results_plot"),
            col_widths=(4, 8)
        )
    )
)

def get_visible(pct):
    nonsig_idx = np.where(~is_sig_pos)[0]
    n_hide     = int(len(nonsig_idx) * pct / 100)
    np.random.seed(7)
    hidden_idx = np.random.choice(nonsig_idx, size=n_hide, replace=False)
    visible    = np.ones(N, dtype=bool)
    visible[hidden_idx] = False
    return visible, hidden_idx

def server(input, output, session):

    @render.plot
    def results_plot():
        pct             = input.bias_pct()
        visible, _      = get_visible(pct)
        hidden          = ~visible
        reported_mean   = np.mean(effects[visible]) if visible.sum() > 0 else TRUE_EFFECT

        fig, ax = plt.subplots(figsize=(11, 7))

        for rank, idx in enumerate(order):
            eff = effects[idx]
            if hidden[idx]:
                ax.barh(rank, eff, color='#dfe6e9', alpha=0.55, height=0.7,
                        edgecolor='#b2bec3', linewidth=0.5)
            elif is_sig_pos[idx]:
                ax.barh(rank, eff, color='#3498db', alpha=0.82, height=0.7)
            else:
                ax.barh(rank, eff, color='#a8d8ea', alpha=0.75, height=0.7)

        # Wahrer Effekt
        ax.axvline(TRUE_EFFECT, color='#2c3e50', linewidth=1.8, linestyle='dotted')

        # Metaanalytischer Durchschnitt
        ueberschaetz = reported_mean - TRUE_EFFECT
        line_color   = '#27ae60' if ueberschaetz < 0.5 else '#c0392b'
        ax.axvline(reported_mean, color=line_color, linewidth=3)

        ax.set_xlabel("Gemessener Zeitgewinn in Minuten", fontsize=11, labelpad=8)
        # Studien nummerieren: jede Studie behält eine feste Nummer (1..N),
        # platziert an ihrer nach Effektgröße sortierten Position.
        ax.set_yticks(range(N))
        ax.set_yticklabels([f"Studie {idx + 1}" for idx in order], fontsize=6.5)
        ax.set_ylabel("Studie", fontsize=11, labelpad=8)
        ax.set_xlim(-3, 9)
        ax.set_ylim(-0.8, N + 0.5)

        title_color = '#27ae60' if pct == 0 else ('#e67e22' if pct < 70 else '#c0392b')
        ax.set_title(
            f"40 SmartRail-Studien  |  sichtbar: {visible.sum()}, "
            f"nie eingereicht: {hidden.sum()}",
            fontweight='bold', fontsize=11, color=title_color, pad=14
        )

        patch_sig = mpatches.Patch(color='#3498db', alpha=0.82, label='Publiziert (signifikant)')
        patch_ns  = mpatches.Patch(color='#a8d8ea', alpha=0.75, label='Publiziert (nicht signifikant)')
        patch_hid = mpatches.Patch(color='#dfe6e9', alpha=0.7,  label='Nie eingereicht (Schublade)')
        line_true = Line2D([0], [0], color='#2c3e50', linewidth=1.8, linestyle='dotted',
                           label=f'Wahrer Effekt ({TRUE_EFFECT:.0f} Min)')
        line_gut  = Line2D([0], [0], color=line_color, linewidth=3,
                           label=f'Metaanalytischer Ø: {reported_mean:.2f} Min')

        ax.legend(handles=[patch_sig, patch_ns, patch_hid, line_true, line_gut],
                  loc='lower right', fontsize=9, framealpha=0.92)
        ax.spines['top'].set_visible(False)
        ax.spines['right'].set_visible(False)
        fig.subplots_adjust(left=0.06, right=0.97, top=0.88, bottom=0.12)
        return fig

    @render.ui
    def bias_panel():
        pct           = input.bias_pct()
        visible, _    = get_visible(pct)
        reported_mean = np.mean(effects[visible]) if visible.sum() > 0 else TRUE_EFFECT
        ueberschaetz  = reported_mean - TRUE_EFFECT
        n_hidden      = N - visible.sum()

        if pct == 0:
            interpretation = (f"Vollständiges Bild. Der metaanalytische Durchschnitt liegt "
                              f"nahe am wahren Effekt von {TRUE_EFFECT:.0f} Min.")
            interp_color   = "#27ae60"
        elif ueberschaetz < 0.5:
            interpretation = "Leichte Überschätzung. Der Bias ist noch gering."
            interp_color   = "#e67e22"
        else:
            interpretation = (
                f"Starke Überschätzung! Die Literatur suggeriert {reported_mean:.1f} Min, "
                f"der wahre Effekt beträgt nur {TRUE_EFFECT:.0f} Min. "
                f"Niemand hat gelogen."
            )
            interp_color = "#c0392b"

        warn  = "color:#c0392b; font-weight:bold;" if n_hidden > 0 else "color:#27ae60;"
        sign  = "+" if ueberschaetz >= 0 else ""

        return ui.div(
            ui.tags.p(
                ui.tags.span("In der Literatur: ", style="font-weight:bold;"),
                f"{visible.sum()} von {N} Studien",
            ),
            ui.tags.p(
                ui.tags.span("Nie eingereicht: ", style="font-weight:bold;"),
                ui.tags.span(f"{n_hidden} Studien", style=warn),
            ),
            ui.tags.p(
                ui.tags.span("Metaanalytischer Durchschnitt: ", style="font-weight:bold;"),
                ui.tags.span(
                    f"{reported_mean:.2f} Minuten",
                    style=f"color:{'#c0392b' if ueberschaetz >= 0.5 else '#27ae60'};"
                          f"font-weight:bold; font-size:14px;"
                ),
            ),
            ui.tags.p(
                ui.tags.span("Überschätzung: ", style="font-weight:bold;"),
                f"{sign}{ueberschaetz:.2f} Min gegenüber dem wahren Effekt "
                f"({TRUE_EFFECT:.0f} Min)",
            ),
            ui.tags.hr(style="margin:8px 0;"),
            ui.tags.p(
                interpretation,
                style=f"color:{interp_color}; font-weight:bold; font-size:12px;"
            ),
            style="background:#f8f9fa; padding:12px; border-radius:8px;"
                  "margin-top:8px; font-size:13px;"
        )

app = App(app_ui, server)