File size: 5,561 Bytes
47c4bf8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
"""Soft radial-order constraint  (fix for error family B: one-layer boundary shifts).

POST-HOC.  Takes the model's probability matrix and the cell centroids; it never looks at
the 768 DINOv2 columns, so it can be re-applied unchanged after the re-fine-tune.

Method
  1. Per section, a monotone radial coordinate r_rel in [0,1]:  r_rel = r / r_surface(theta),
     centre = mean centroid of the cells PREDICTED vascular-core, r_surface = furthest cell
     within +-15 deg of the cell's own angle (all cells of the section, labels not needed).
  2. Tissues get a radial RANK: epidermis/root_cap 0 < exodermis 1 < cortex 2 < endodermis 3
     < pericycle 4 < {stele,xylem,phloem} 5.  The trio shares ONE rank - there is no reliable
     radial order inside it, so the constraint cannot touch stele/xylem/phloem against each other.
  3. Per section, an ordered-logit model of rank against -r_rel is fitted to the model's OWN
     posterior (soft targets, no labels, nothing from the ground truth).  Two passes: the second
     fit uses the once-corrected posterior.
  4. P_final  ~  P_model * P_order ** lambda,  renormalised.

Hyperparameters (s = 0.3, lambda = 2.0, 2 passes) were chosen on the 100 out-of-fold training
sections only; the OOF optimum is a broad plateau (s 0.3-1.2, 2-4 passes, lambda 2).

Usage
  from apply_radial_order import correct
  P_final = correct(P, source_file, cell_id, classes)         # P: (n_cells, n_classes)
"""
import numpy as np
from scipy.optimize import minimize

CLASSES_DEFAULT = ['cortex','endodermis','epidermis','exodermis','pericycle','phloem','root_cap','stele','xylem']
RANK = {'epidermis':0,'root_cap':0,'exodermis':1,'cortex':2,'endodermis':3,'pericycle':4,
        'stele':5,'xylem':5,'phloem':5}
NRANK = 6
CORE = ('endodermis','pericycle','stele','xylem','phloem')
S_SCALE, LAMBDA, PASSES, FLOOR = 0.3, 2.0, 2, 1e-3


def radial_coordinate(cy, cx, cy_all, cx_all, pred_names, win_deg=15.0):
    """r_rel for the cells (cy,cx); the surface envelope uses every cell of the section
    (cy_all,cx_all), which may be a superset (unlabelled cells included)."""
    core = np.isin(pred_names, CORE)
    if core.sum() >= 5:
        y0, x0 = cy[core].mean(), cx[core].mean()
    else:
        y0, x0 = cy_all.mean(), cx_all.mean()
    r_all = np.hypot(cy_all - y0, cx_all - x0); th_all = np.arctan2(cy_all - y0, cx_all - x0)
    r = np.hypot(cy - y0, cx - x0); th = np.arctan2(cy - y0, cx - x0)
    da = np.abs((th[:, None] - th_all[None, :] + np.pi) % (2 * np.pi) - np.pi)
    rs = np.maximum(np.where(da < np.deg2rad(win_deg), r_all[None, :], -1.0).max(1), np.maximum(r, 1.0))
    return r / rs


def _nll(par, v, Q, s):
    t = np.concatenate([[par[0]], par[0] + np.cumsum(np.logaddexp(0.0, par[1:]))])
    z = np.clip((t[None, :] - v[:, None]) / s, -50, 50)
    cum = np.concatenate([np.zeros((len(v), 1)), 1 / (1 + np.exp(-z)), np.ones((len(v), 1))], 1)
    return -(Q * np.log(np.clip(np.diff(cum, axis=1), 1e-9, 1.0))).sum()


def _order_prior(v, Q, s):
    cw = np.clip(np.cumsum(Q.sum(0) + 1e-9)[:-1] / (Q.sum() + 1e-9), 0.01, 0.99)
    t0 = np.maximum.accumulate(np.quantile(v, cw))
    par0 = np.concatenate([[t0[0]], np.log(np.expm1(np.maximum(np.diff(t0), 1e-3)))])
    par = minimize(_nll, par0, args=(v, Q, s), method='L-BFGS-B', options=dict(maxiter=500)).x
    t = np.concatenate([[par[0]], par[0] + np.cumsum(np.logaddexp(0.0, par[1:]))])
    z = np.clip((t[None, :] - v[:, None]) / s, -50, 50)
    cum = np.concatenate([np.zeros((len(v), 1)), 1 / (1 + np.exp(-z)), np.ones((len(v), 1))], 1)
    return np.clip(np.diff(cum, axis=1), 1e-9, 1.0), t


def correct_section(P, cy, cx, classes, cy_all=None, cx_all=None,
                    s=S_SCALE, lam=LAMBDA, passes=PASSES):
    cy_all = cy if cy_all is None else cy_all; cx_all = cx if cx_all is None else cx_all
    pred = np.asarray(classes)[P.argmax(1)]
    v = -radial_coordinate(cy, cx, cy_all, cx_all, pred)
    Pc = P
    for k in range(passes):
        Q = np.zeros((len(P), NRANK))
        for j, c in enumerate(classes):
            Q[:, RANK[c]] += Pc[:, j]
        Q /= np.maximum(Q.sum(1, keepdims=True), 1e-12)
        Po, _ = _order_prior(v, Q, s)
        Po = Po + FLOOR; Po /= Po.sum(1, keepdims=True)
        pri = np.stack([Po[:, RANK[c]] for c in classes], 1)
        F = P * pri ** lam; Pc = F / F.sum(1, keepdims=True)
    return Pc


def correct(P, source_file, cell_id, classes=None, geom=None, **kw):
    """P (n,k) posterior; source_file (n,) section of each row.  geom: optional dict
    section -> (cy_all, cx_all, cy, cx) if the centroids are not in geom.npz."""
    classes = classes or CLASSES_DEFAULT
    if geom is None:
        raise ValueError(
            "correct() needs cell centroids. Pass geom={source_file: (cy_all, cx_all, "
            "{cell_id: (cy, cx)})}. The research build read these from a cached "
            "geom.npz that is not part of this package.")
        d = None  # unreachable; kept so the original block below still parses
        gsf = d['source_file'].astype(str); gid = d['cell_id'].astype(np.int64)
        geom = {}
        for s_ in np.unique(source_file):
            m = gsf == s_
            geom[s_] = (d['cy'][m], d['cx'][m], dict(zip(gid[m], zip(d['cy'][m], d['cx'][m]))))
    out = P.copy()
    for s_ in np.unique(source_file):
        m = source_file == s_
        cy_all, cx_all, look = geom[s_]
        yx = np.array([look[int(c)] for c in np.asarray(cell_id)[m]])
        out[m] = correct_section(P[m], yx[:, 0], yx[:, 1], classes, cy_all, cx_all, **kw)
    return out