setuhq 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- setuhq/__init__.py +10 -0
- setuhq/aggregate.py +208 -0
- setuhq/agreement.py +83 -0
- setuhq/app.py +3163 -0
- setuhq/batch.py +168 -0
- setuhq/benchmark.py +147 -0
- setuhq/benchmark_toi.py +954 -0
- setuhq/brand.py +101 -0
- setuhq/calibrate.py +196 -0
- setuhq/catalogue.py +653 -0
- setuhq/cli.py +630 -0
- setuhq/codenote.py +109 -0
- setuhq/config.py +159 -0
- setuhq/contrib.py +592 -0
- setuhq/dashboard.py +415 -0
- setuhq/data/claret2017_tess_quadratic.json +1 -0
- setuhq/data/secondary_calibration.json +2114 -0
- setuhq/dipslugs.py +112 -0
- setuhq/discover.py +372 -0
- setuhq/docket.py +1644 -0
- setuhq/dvtests.py +639 -0
- setuhq/ephemeris.py +84 -0
- setuhq/everything.py +580 -0
- setuhq/exofop.py +265 -0
- setuhq/fonts/OFL-geist-mono.txt +93 -0
- setuhq/fonts/OFL-geist.txt +93 -0
- setuhq/fonts/OFL-unbounded.txt +93 -0
- setuhq/fonts/files/geist-gyByhwUxId8gMEwSGFWNOITddY4.woff2 +0 -0
- setuhq/fonts/files/geist-gyByhwUxId8gMEwcGFWNOITd.woff2 +0 -0
- setuhq/fonts/files/geistmono-or3nQ6H-1_WfwkMZI_qYFrcdmhHkjko.woff2 +0 -0
- setuhq/fonts/files/geistmono-or3nQ6H-1_WfwkMZI_qYFrkdmhHkjkotbA.woff2 +0 -0
- setuhq/fonts/files/unbounded-Yq6W-LOTXCb04q32xlpwtcZfvRIkSYZH.woff2 +0 -0
- setuhq/fonts/files/unbounded-Yq6W-LOTXCb04q32xlpwu8ZfvRIkSQ.woff2 +0 -0
- setuhq/fonts/fonts.css +131 -0
- setuhq/galaxy.py +1639 -0
- setuhq/injection.py +124 -0
- setuhq/leaderboard.py +639 -0
- setuhq/learn.py +162 -0
- setuhq/lightcurve.py +55 -0
- setuhq/loaders.py +269 -0
- setuhq/mcp_server.py +171 -0
- setuhq/methods.py +3240 -0
- setuhq/mission.py +352 -0
- setuhq/names.py +156 -0
- setuhq/objects.py +36 -0
- setuhq/pipeline.py +874 -0
- setuhq/pixel.py +904 -0
- setuhq/plate.py +733 -0
- setuhq/preprocess.py +183 -0
- setuhq/py.typed +0 -0
- setuhq/render.py +37 -0
- setuhq/report.py +357 -0
- setuhq/reviewers/__init__.py +38 -0
- setuhq/reviewers/base.py +68 -0
- setuhq/reviewers/claude.py +102 -0
- setuhq/reviewers/claude_code.py +107 -0
- setuhq/reviewers/gemini.py +101 -0
- setuhq/reviewers/http.py +155 -0
- setuhq/reviewers/openai_compat.py +148 -0
- setuhq/reviewers/sentinel.py +195 -0
- setuhq/reviewers/simulated.py +105 -0
- setuhq/reviewers/task.py +134 -0
- setuhq/routes.py +231 -0
- setuhq/search.py +421 -0
- setuhq/site.py +915 -0
- setuhq/starsearch.py +278 -0
- setuhq/stellar.py +71 -0
- setuhq/store.py +157 -0
- setuhq/story.py +316 -0
- setuhq/stubs.py +152 -0
- setuhq/subjects.py +80 -0
- setuhq/sweep.py +907 -0
- setuhq/tools.py +637 -0
- setuhq/transitfit.py +499 -0
- setuhq/validate.py +438 -0
- setuhq/variables.py +650 -0
- setuhq/vet.py +142 -0
- setuhq-0.1.0.dist-info/METADATA +140 -0
- setuhq-0.1.0.dist-info/RECORD +85 -0
- setuhq-0.1.0.dist-info/WHEEL +5 -0
- setuhq-0.1.0.dist-info/entry_points.txt +5 -0
- setuhq-0.1.0.dist-info/licenses/LICENSE +661 -0
- setuhq-0.1.0.dist-info/licenses/LICENSING.md +120 -0
- setuhq-0.1.0.dist-info/licenses/NOTICE +130 -0
- setuhq-0.1.0.dist-info/top_level.txt +1 -0
setuhq/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 Ashforde OÜ
|
|
3
|
+
"""SetuHQ: an open-source transit search in the style of Planet Hunters CHEOPS.
|
|
4
|
+
|
|
5
|
+
Volunteers are replaced by a reviewer panel. The panel can include simulated
|
|
6
|
+
volunteers (deterministic detectors) and Sentinels (vision models, Claude by default, given
|
|
7
|
+
the same task). Consensus is weighted by skill measured on injected transits.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
setuhq/aggregate.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 Ashforde OÜ
|
|
3
|
+
"""Consensus: turn many independent classifications into ranked events.
|
|
4
|
+
|
|
5
|
+
This follows the Planet Hunters idea. Marks from different reviewers that land
|
|
6
|
+
on the same dip are clustered in time. Each cluster is scored by the share of
|
|
7
|
+
the panel's skill-weighted reviewers who marked it. A cluster counts only if
|
|
8
|
+
the subject is also labelled ``potential_planet`` by enough of the weighted panel.
|
|
9
|
+
|
|
10
|
+
Clustering is single-linkage on intervals: marks are taken in order of start
|
|
11
|
+
time, and a mark joins the current cluster when its interval overlaps the
|
|
12
|
+
cluster's interval (the union of its marks so far) or its centre is within
|
|
13
|
+
``gap_days`` of the previous mark's centre. A wide mark that covers a dip
|
|
14
|
+
therefore counts even when its centre sits off the dip's centre.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections import defaultdict
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from statistics import median
|
|
22
|
+
|
|
23
|
+
from setuhq.reviewers.base import POTENTIAL_PLANET, Classification
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Consensus:
|
|
28
|
+
subject_id: str
|
|
29
|
+
t_start: float
|
|
30
|
+
t_end: float
|
|
31
|
+
score: float
|
|
32
|
+
n_reviewers: int
|
|
33
|
+
reviewers: tuple[str, ...]
|
|
34
|
+
planet_fraction: float
|
|
35
|
+
# The marks that counted, one per reviewer: (reviewer_id, t_start, t_end).
|
|
36
|
+
marks: tuple = ()
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def center(self) -> float:
|
|
40
|
+
return 0.5 * (self.t_start + self.t_end)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cluster_marks(entries, gap_days: float) -> list[list[tuple[str, float, float]]]:
|
|
44
|
+
"""Group ``(reviewer_id, t_start, t_end)`` entries into time clusters. Input order does not matter.
|
|
45
|
+
|
|
46
|
+
A mark joins the current cluster when its interval overlaps the cluster's
|
|
47
|
+
interval, or when its centre is within ``gap_days`` of the previous mark's centre.
|
|
48
|
+
"""
|
|
49
|
+
ordered = sorted(entries, key=lambda e: (e[1], e[2]))
|
|
50
|
+
clusters: list[list[tuple[str, float, float]]] = []
|
|
51
|
+
hi = last_center = None
|
|
52
|
+
for entry in ordered:
|
|
53
|
+
t_start, t_end = entry[1], entry[2]
|
|
54
|
+
center = 0.5 * (t_start + t_end)
|
|
55
|
+
joins = hi is not None and (t_start <= hi or abs(center - last_center) <= gap_days)
|
|
56
|
+
if joins:
|
|
57
|
+
clusters[-1].append(entry)
|
|
58
|
+
hi = max(hi, t_end)
|
|
59
|
+
else:
|
|
60
|
+
clusters.append([entry])
|
|
61
|
+
hi = t_end
|
|
62
|
+
last_center = center
|
|
63
|
+
return clusters
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _one_mark_per_reviewer(cluster) -> dict[str, tuple[float, float]]:
|
|
67
|
+
"""Each reviewer's mark in a cluster: the one whose centre is nearest the cluster's median centre."""
|
|
68
|
+
middle = median(0.5 * (a + b) for _, a, b in cluster)
|
|
69
|
+
chosen: dict[str, tuple[float, float]] = {}
|
|
70
|
+
for reviewer_id, t_start, t_end in cluster:
|
|
71
|
+
best = chosen.get(reviewer_id)
|
|
72
|
+
if best is None or abs(0.5 * (t_start + t_end) - middle) < abs(0.5 * sum(best) - middle):
|
|
73
|
+
chosen[reviewer_id] = (t_start, t_end)
|
|
74
|
+
return chosen
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def aggregate(
|
|
78
|
+
classifications: list[Classification],
|
|
79
|
+
weights: dict[str, float] | None = None,
|
|
80
|
+
*,
|
|
81
|
+
gap_days: float = 0.05,
|
|
82
|
+
min_score: float = 0.5,
|
|
83
|
+
min_planet_fraction: float = 0.5,
|
|
84
|
+
) -> list[Consensus]:
|
|
85
|
+
"""Return consensus events that pass both thresholds, grouped by subject and ordered by score."""
|
|
86
|
+
weights = weights or {}
|
|
87
|
+
by_subject: dict[str, list[Classification]] = defaultdict(list)
|
|
88
|
+
for classification in classifications:
|
|
89
|
+
by_subject[classification.subject_id].append(classification)
|
|
90
|
+
|
|
91
|
+
results: list[Consensus] = []
|
|
92
|
+
for subject_id in sorted(by_subject):
|
|
93
|
+
panel = _latest_per_reviewer(by_subject[subject_id])
|
|
94
|
+
total_weight = sum(_weight(weights, c.reviewer_id) for c in panel.values())
|
|
95
|
+
if total_weight <= 0:
|
|
96
|
+
continue
|
|
97
|
+
planet_weight = sum(_weight(weights, c.reviewer_id) for c in panel.values() if c.label == POTENTIAL_PLANET)
|
|
98
|
+
planet_fraction = planet_weight / total_weight
|
|
99
|
+
if planet_fraction < min_planet_fraction:
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
entries = [(c.reviewer_id, m.t_start, m.t_end) for c in panel.values() for m in c.marks]
|
|
103
|
+
for cluster in cluster_marks(entries, gap_days):
|
|
104
|
+
first_mark = _one_mark_per_reviewer(cluster)
|
|
105
|
+
score = sum(_weight(weights, r) for r in first_mark) / total_weight
|
|
106
|
+
if score < min_score:
|
|
107
|
+
continue
|
|
108
|
+
results.append(
|
|
109
|
+
Consensus(
|
|
110
|
+
subject_id=subject_id,
|
|
111
|
+
t_start=median(t for t, _ in first_mark.values()),
|
|
112
|
+
t_end=median(t for _, t in first_mark.values()),
|
|
113
|
+
score=score,
|
|
114
|
+
n_reviewers=len(first_mark),
|
|
115
|
+
reviewers=tuple(sorted(first_mark)),
|
|
116
|
+
planet_fraction=planet_fraction,
|
|
117
|
+
marks=tuple((r, a, b) for r, (a, b) in sorted(first_mark.items())),
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
return sorted(_merge_duplicates(results, gap_days), key=lambda c: c.score, reverse=True)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def counting_rule(gap_days: float, min_score: float) -> str:
|
|
124
|
+
"""The rule that decides which marks count toward a consensus event, in words."""
|
|
125
|
+
return (
|
|
126
|
+
f"marks are taken in order of start time and a mark joins the cluster when its interval overlaps the "
|
|
127
|
+
f"cluster's interval or its centre lies within {gap_days:g} d of the previous mark's centre (interval "
|
|
128
|
+
f"single-linkage clustering, aggregate.py); each reviewer counts once, with the mark nearest the cluster's "
|
|
129
|
+
f"median centre; a cluster whose weighted share is below {min_score:g} is dropped, and of two clusters on one "
|
|
130
|
+
f"dip only the stronger is kept"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def audit_marks(event: Consensus, classifications: list[Classification], gap_days: float) -> list[dict]:
|
|
135
|
+
"""For one event, every reviewer's marks over its window and whether each counted.
|
|
136
|
+
|
|
137
|
+
This only reports the rule in :func:`aggregate`; it does not change it. A
|
|
138
|
+
mark counts when it is the reviewer's mark in the event's cluster
|
|
139
|
+
(``event.marks``). ``overlap_frac`` is the share of the consensus window
|
|
140
|
+
[t_start, t_end] the mark covers. ``centre_gap_days`` is the distance from
|
|
141
|
+
the mark's centre to the nearest centre of a counted mark (another one, for
|
|
142
|
+
a counted mark). A mark that overlaps no counted mark and whose centre is
|
|
143
|
+
more than ``gap_days`` from every counted centre fell in a separate cluster.
|
|
144
|
+
"""
|
|
145
|
+
panel = _latest_per_reviewer([c for c in classifications if c.subject_id == event.subject_id])
|
|
146
|
+
counted = {r: (a, b) for r, a, b in event.marks}
|
|
147
|
+
centres = {r: 0.5 * (a + b) for r, (a, b) in counted.items()}
|
|
148
|
+
width = event.t_end - event.t_start
|
|
149
|
+
out = []
|
|
150
|
+
for reviewer_id in sorted(panel):
|
|
151
|
+
rows = []
|
|
152
|
+
for m in panel[reviewer_id].marks:
|
|
153
|
+
overlap = max(0.0, min(m.t_end, event.t_end) - max(m.t_start, event.t_start))
|
|
154
|
+
is_counted = counted.get(reviewer_id) == (m.t_start, m.t_end)
|
|
155
|
+
if overlap <= 0 and not is_counted:
|
|
156
|
+
continue
|
|
157
|
+
centre = 0.5 * (m.t_start + m.t_end)
|
|
158
|
+
others = [c for r, c in centres.items() if not (is_counted and r == reviewer_id)]
|
|
159
|
+
rows.append({
|
|
160
|
+
"t_start": m.t_start,
|
|
161
|
+
"t_end": m.t_end,
|
|
162
|
+
"overlap_frac": overlap / width if width > 0 else None,
|
|
163
|
+
"centre_gap_days": min(abs(centre - c) for c in others) if others else None,
|
|
164
|
+
"counted": is_counted,
|
|
165
|
+
})
|
|
166
|
+
out.append({
|
|
167
|
+
"reviewer_id": reviewer_id,
|
|
168
|
+
"counted": reviewer_id in counted,
|
|
169
|
+
# The counted mark's overlap when one counted, else the largest overlap of any mark.
|
|
170
|
+
"overlap_frac": (next((r["overlap_frac"] for r in rows if r["counted"]), None) if reviewer_id in counted
|
|
171
|
+
else max((r["overlap_frac"] or 0.0 for r in rows), default=0.0)) if width > 0 else None,
|
|
172
|
+
"marks": rows,
|
|
173
|
+
})
|
|
174
|
+
return out
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _merge_duplicates(events: list[Consensus], gap_days: float) -> list[Consensus]:
|
|
178
|
+
"""Keep the strongest of any two events in one subject whose centres are close.
|
|
179
|
+
|
|
180
|
+
Two events are the same dip when their centres lie within half the sum of
|
|
181
|
+
their durations plus the clustering gap. Clustering alone can split one dip
|
|
182
|
+
when reviewers' boundaries disagree, and this step puts it back together.
|
|
183
|
+
"""
|
|
184
|
+
kept: list[Consensus] = []
|
|
185
|
+
# Ties go to the event with more reviewers, then to the wider one: the more complete dip wins.
|
|
186
|
+
ranked = sorted(events, key=lambda c: (c.score, c.n_reviewers, c.t_end - c.t_start), reverse=True)
|
|
187
|
+
for event in ranked:
|
|
188
|
+
duplicate = any(
|
|
189
|
+
other.subject_id == event.subject_id
|
|
190
|
+
and abs(other.center - event.center)
|
|
191
|
+
<= 0.5 * ((other.t_end - other.t_start) + (event.t_end - event.t_start)) + gap_days
|
|
192
|
+
for other in kept
|
|
193
|
+
)
|
|
194
|
+
if not duplicate:
|
|
195
|
+
kept.append(event)
|
|
196
|
+
return kept
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _latest_per_reviewer(classifications: list[Classification]) -> dict[str, Classification]:
|
|
200
|
+
"""One classification per reviewer per subject. A later duplicate replaces an earlier one."""
|
|
201
|
+
panel: dict[str, Classification] = {}
|
|
202
|
+
for classification in classifications:
|
|
203
|
+
panel[classification.reviewer_id] = classification
|
|
204
|
+
return panel
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _weight(weights: dict[str, float], reviewer_id: str) -> float:
|
|
208
|
+
return float(weights.get(reviewer_id, 1.0))
|
setuhq/agreement.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 Ashforde OÜ
|
|
3
|
+
"""How independent are the Sentinels? Pairwise agreement (φ) on the same stars.
|
|
4
|
+
|
|
5
|
+
Two reviewers that share a model make correlated errors and count as one voter, not two:
|
|
6
|
+
on the stated 40-star subsample of the real-TOI benchmark the two Claude personas agree with
|
|
7
|
+
φ = 0.89. Sentinels on different providers may disagree more, which is what makes a panel
|
|
8
|
+
worth having. This module measures it, from per-star outcomes only:
|
|
9
|
+
|
|
10
|
+
hits = {star: {reviewer_id: True/False}} # did the reviewer mark the TOI's dip as a planet
|
|
11
|
+
|
|
12
|
+
φ is the Pearson correlation of two binary outcomes (the Matthews coefficient):
|
|
13
|
+
|
|
14
|
+
φ = (n11·n00 − n10·n01) / sqrt((n11+n10)(n01+n00)(n11+n01)(n10+n00))
|
|
15
|
+
|
|
16
|
+
It is None when either reviewer gave the same answer on every star (the denominator is zero).
|
|
17
|
+
Only stars both reviewers answered are compared. ``scripts/benchmark_real_tois.py sentinels``
|
|
18
|
+
collects the outcomes; its ``report`` step writes the table.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from itertools import combinations
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def phi(pairs: list[tuple[bool, bool]]) -> dict:
|
|
27
|
+
"""Agreement of two reviewers over paired binary outcomes: counts, fraction agreeing and φ."""
|
|
28
|
+
n11 = sum(1 for a, b in pairs if a and b)
|
|
29
|
+
n00 = sum(1 for a, b in pairs if not a and not b)
|
|
30
|
+
n10 = sum(1 for a, b in pairs if a and not b)
|
|
31
|
+
n01 = sum(1 for a, b in pairs if not a and b)
|
|
32
|
+
den = ((n11 + n10) * (n01 + n00) * (n11 + n01) * (n10 + n00)) ** 0.5
|
|
33
|
+
return {"n": len(pairs), "agree": n11 + n00, "n11": n11, "n10": n10, "n01": n01, "n00": n00,
|
|
34
|
+
"phi": (n11 * n00 - n10 * n01) / den if den else None}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def pairwise(hits: dict[str, dict[str, bool]], reviewers: list[str] | None = None,
|
|
38
|
+
identities: dict[str, dict] | None = None) -> list[dict]:
|
|
39
|
+
"""φ for every pair of reviewers, over the stars both answered.
|
|
40
|
+
|
|
41
|
+
``identities`` ({reviewer: {"provider": ..., "model": ...}}) labels each pair as the same model
|
|
42
|
+
(two personas of one model: expect high φ) or different providers/models (the diversity being measured).
|
|
43
|
+
"""
|
|
44
|
+
ids = reviewers or sorted({rid for per in hits.values() for rid in per})
|
|
45
|
+
identities = identities or {}
|
|
46
|
+
out = []
|
|
47
|
+
for a, b in combinations(ids, 2):
|
|
48
|
+
pairs = [(per[a], per[b]) for per in hits.values() if a in per and b in per]
|
|
49
|
+
ia, ib = identities.get(a) or {}, identities.get(b) or {}
|
|
50
|
+
same = bool(ia) and bool(ib) and (ia.get("provider"), ia.get("model")) == (ib.get("provider"), ib.get("model"))
|
|
51
|
+
out.append({"a": a, "b": b, **phi(pairs),
|
|
52
|
+
"same_model": same if ia and ib else None,
|
|
53
|
+
"a_provider": ia.get("provider"), "a_model": ia.get("model"),
|
|
54
|
+
"b_provider": ib.get("provider"), "b_model": ib.get("model")})
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def constant_reviewers(hits: dict[str, dict[str, bool]], reviewers: list[str]) -> list[str]:
|
|
59
|
+
"""Reviewers that gave the same answer on every star they answered (or answered none). φ with them is
|
|
60
|
+
undefined, so they carry no information about independence and are left out of the voter count."""
|
|
61
|
+
out = []
|
|
62
|
+
for rid in reviewers:
|
|
63
|
+
answers = {per[rid] for per in hits.values() if rid in per}
|
|
64
|
+
if len(answers) < 2:
|
|
65
|
+
out.append(rid)
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def effective_voters(table: list[dict], reviewers: list[str], exclude: list[str] = ()) -> float | None:
|
|
70
|
+
"""A rough count of independent voters: k / (1 + (k − 1)·mean φ), with φ < 0 treated as 0.
|
|
71
|
+
|
|
72
|
+
k reviewers whose outcomes all correlate at φ act like this many independent ones (the design-effect
|
|
73
|
+
formula). ``exclude`` (:func:`constant_reviewers`) is left out of k and of the pairs, so k counts only
|
|
74
|
+
reviewers whose every pair has a φ. None when fewer than two remain, or a remaining pair has no φ
|
|
75
|
+
(no stars in common).
|
|
76
|
+
"""
|
|
77
|
+
keep = [r for r in reviewers if r not in set(exclude)]
|
|
78
|
+
pairs = [p for p in table if p["a"] in keep and p["b"] in keep]
|
|
79
|
+
k = len(keep)
|
|
80
|
+
if k < 2 or not pairs or any(p["phi"] is None for p in pairs):
|
|
81
|
+
return None
|
|
82
|
+
mean = sum(max(0.0, p["phi"]) for p in pairs) / len(pairs)
|
|
83
|
+
return k / (1 + (k - 1) * mean)
|