Skip to content

feat: LDSC genetic correlation logic#1262

Open
xyg123 wants to merge 6 commits into
devfrom
xg1_ldsc_gencor
Open

feat: LDSC genetic correlation logic#1262
xyg123 wants to merge 6 commits into
devfrom
xg1_ldsc_gencor

Conversation

@xyg123

@xyg123 xyg123 commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

✨ Context

LDSC genetic correlation will provide context for assessing genetic relatedness between diseases and traits, the results will be informative of vertical/horizontal pleiotropy.

🛠 What does this PR implement

This PR implements the logic for computing pairwise genetic correlations from raw sumstats, it also includes an overhaul or the heritability estimates to hide the orchestration logic from the gentropy business layer. As well as a general refactoring of the codebase for a modular structure, similar to the original python implementation of LDSC.

🚦 Before submitting

  • Do these changes cover one single feature (one change at a time)?
  • Did you read the contributor guideline?
  • Did you make sure to update the documentation with your changes?
  • Did you make sure there is no commented out code in this PR?
  • Did you follow conventional commits standards in PR title and commit messages?
  • Did you make sure the branch is up-to-date with the dev branch?
  • Did you write any new necessary tests?
  • Did you make sure the changes pass local tests (make test)?
  • Did you make sure the changes pass pre-commit rules (e.g uv run pre-commit run --all-files)?

@xyg123
xyg123 requested a review from project-defiant July 15, 2026 09:49
@xyg123 xyg123 changed the title LDSC genetic correlation logic feat: LDSC genetic correlation logic Jul 15, 2026

@project-defiant project-defiant left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to catch up with the Step before I could look over the Genetic Correlation. Overall I think this PR will not be merged, as it has a lot of obsolete, or unnecessary changes. I did not review the individual modules in the ldsc, just the rg.

Comment thread src/gentropy/ldsc.py
n_blocks: int = 200,
intercept: float | None = None,
max_rows_for_collection: int = 15_000_000,
max_rows_for_collection: int = 20_000_000,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why changed to 20M?

@@ -1,3 +1,4 @@
# Deprecated: import from gentropy.method.ldsc instead. This file will be removed in a future release.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import warnings
warnings.warn("import from gentropy.method.ldsc instead. This file will be removed in a future release.", DeprecationWarning,
              stacklevel=2)

# normal module code

See https://stackoverflow.com/questions/30093490/how-to-declare-a-module-deprecated-in-python

Comment thread src/gentropy/ldsc_rg.py
Comment on lines +365 to +372
# Rename columns to avoid ambiguity
t1 = prepared_1.select(
F.col("chromosome"), F.col("position"),
F.col("ref"), F.col("alt"),
F.col("beta").alias("beta1"),
F.col("standardError").alias("se1"),
F.col("sampleSize").alias("n1"),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do you know that t1 is correctly flipped ?

Comment thread src/gentropy/ldsc_rg.py
Comment on lines +373 to +411
t2 = prepared_2.select(
F.col("chromosome"), F.col("position"),
F.col("ref"), F.col("alt"),
F.col("beta").alias("beta2"),
F.col("standardError").alias("se2"),
F.col("sampleSize").alias("n2"),
)

# Direct join (same ref/alt)
t2_direct = t2.withColumn("flipped", F.lit(False))

# Flipped join: swap ref/alt in trait 2, negate beta2 later
t2_flipped = t2.select(
F.col("chromosome"),
F.col("position"),
F.col("alt").alias("ref"), # swap
F.col("ref").alias("alt"), # swap
F.col("beta2"),
F.col("se2"),
F.col("n2"),
).withColumn("flipped", F.lit(True))

t2_combined = t2_direct.unionByName(t2_flipped).dropDuplicates(join_cols)

# Negate beta2 where alleles were flipped
t2_combined = t2_combined.withColumn(
"beta2",
F.when(F.col("flipped"), -F.col("beta2")).otherwise(F.col("beta2")),
).drop("flipped")

merged = (
t1
.join(t2_combined, on=join_cols, how="inner")
.join(ld_df, on=join_cols, how="inner")
.dropDuplicates(join_cols)
)

return merged, m_ldsc

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can reduce the amount of ops

t2_direct = prepared_2.select(
    "chromosome", 
    "position, 
    "ref", 
    "alt", 
    f.col("beta").alias("beta2"), 
    f.col("standardErrro").alias("se")
)
t2_flipped = t2_direct.select(
    "chromosome",
    "position",
    f.col("alt").alias("ref"),
    f.col("ref").alias("alt"),
    (f.col("beta2") * -1).alias("beta2"),
    "se2",
    "n2"
)
t2_combined = t2.unionByName(t2_flipped)

Couple of notes:

  • This way you do not need to compute the (beta2 * -1) over n_row = N * 2.
  • I would argue that this does not need dedup, as ref != alt and you already deduplicated on the sumstat level in previous functions.
  • The flipped column is redundant
  • You should definately persist and force the compute before flipping. This will speed up.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is not a part of genetic correlation ?

Comment on lines +13 to +17
beta1: np.ndarray,
se1: np.ndarray,
N1: np.ndarray,
beta2: np.ndarray,
se2: np.ndarray,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of passing 4 x float array (N,1) pass 2 with Zscores. Will reduce the space complexity.

Comment on lines +51 to +99
for arr in (beta1, se1, N1, beta2, se2, N2, ld, w_ld):
arr = np.asarray(arr, dtype=float)
if arr.ndim != 1:
raise ValueError("All input arrays must be 1D.")

beta1 = np.asarray(beta1, dtype=float)
se1 = np.asarray(se1, dtype=float)
N1 = np.asarray(N1, dtype=float)
beta2 = np.asarray(beta2, dtype=float)
se2 = np.asarray(se2, dtype=float)
N2 = np.asarray(N2, dtype=float)
ld = np.asarray(ld, dtype=float)
w_ld = np.asarray(w_ld, dtype=float)

z1 = beta1 / se1
z2 = beta2 / se2
n = z1.shape[0]

M_mat = np.array([[float(M_ldsc_scalar)]])
x = ld.reshape((n, 1))
w = w_ld.reshape((n, 1))

# Step 1: Estimate h2 for each trait
def _run_hsq(z: np.ndarray, N: np.ndarray) -> tuple[float, float]:
"""Run Hsq regression on a single trait and return (h2, h2_se).

Args:
z (np.ndarray): Z-scores for the trait, shape (n_snp,).
N (np.ndarray): Sample sizes, shape (n_snp,).

Returns:
tuple[float, float]: Heritability estimate and its standard error.
"""
chisq = z ** 2
y_h = chisq.reshape((n, 1))
N_mat = N.reshape((n, 1))
step_cutoff = twostep if intercept is None else None
hsqhat = Hsq(
y_h, x, w, N_mat, M_mat,
n_blocks=n_blocks,
intercept=intercept,
twostep=step_cutoff,
)
h2 = float(max(hsqhat.tot, 0.0))
h2_se = float(hsqhat.tot_se)
return h2, h2_se

h2_1, h2_1_se = _run_hsq(z1, N1)
h2_2, h2_2_se = _run_hsq(z2, N2)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given this is public method, that shall be called by external users, this is the only spot where the shape validation should happen. Every other method downstream, called in this ones call stack do not need to re-verify these assumptions.

Suggested change
for arr in (beta1, se1, N1, beta2, se2, N2, ld, w_ld):
arr = np.asarray(arr, dtype=float)
if arr.ndim != 1:
raise ValueError("All input arrays must be 1D.")
beta1 = np.asarray(beta1, dtype=float)
se1 = np.asarray(se1, dtype=float)
N1 = np.asarray(N1, dtype=float)
beta2 = np.asarray(beta2, dtype=float)
se2 = np.asarray(se2, dtype=float)
N2 = np.asarray(N2, dtype=float)
ld = np.asarray(ld, dtype=float)
w_ld = np.asarray(w_ld, dtype=float)
z1 = beta1 / se1
z2 = beta2 / se2
n = z1.shape[0]
M_mat = np.array([[float(M_ldsc_scalar)]])
x = ld.reshape((n, 1))
w = w_ld.reshape((n, 1))
# Step 1: Estimate h2 for each trait
def _run_hsq(z: np.ndarray, N: np.ndarray) -> tuple[float, float]:
"""Run Hsq regression on a single trait and return (h2, h2_se).
Args:
z (np.ndarray): Z-scores for the trait, shape (n_snp,).
N (np.ndarray): Sample sizes, shape (n_snp,).
Returns:
tuple[float, float]: Heritability estimate and its standard error.
"""
chisq = z ** 2
y_h = chisq.reshape((n, 1))
N_mat = N.reshape((n, 1))
step_cutoff = twostep if intercept is None else None
hsqhat = Hsq(
y_h, x, w, N_mat, M_mat,
n_blocks=n_blocks,
intercept=intercept,
twostep=step_cutoff,
)
h2 = float(max(hsqhat.tot, 0.0))
h2_se = float(hsqhat.tot_se)
return h2, h2_se
h2_1, h2_1_se = _run_hsq(z1, N1)
h2_2, h2_2_se = _run_hsq(z2, N2)
arrays = (beta1, se1, N1, beta2, se2, N2, ld, w_ld)
expected_shape = beta1.shape
if len(expected_shape) != 1 or any(arr.shape != expected_shape for arr in arrays):
raise ValueError("All input arrays must have the same 1D shape.")
n = expected_shape[0]
z1 = (beta1 / se1).reshape((n, 1))
z2 = (beta2 / se2).reshape((n, 1))
N1 = N1.reshape((n, 1))
N2 = N2.reshape((n, 1))
x = ld.reshape((n, 1))
w = w_ld.reshape((n, 1))
M_mat = np.array([[float(M_ldsc_scalar)]])
def _run_hsq(z: np.ndarray, N: np.ndarray) -> tuple[float, float]:
chisq = z**2
step_cutoff = twostep if intercept is None else None
hsqhat = Hsq(
chisq,
x,
w,
N,
M_mat,
n_blocks=n_blocks,
intercept=intercept,
twostep=step_cutoff,
)
h2 = float(max(hsqhat.tot, 0.0))
h2_se = float(hsqhat.tot_se)
return h2, h2_se
h2_1, h2_1_se = _run_hsq(z1, N1)
h2_2, h2_2_se = _run_hsq(z2, N2)

intercept_out = _as_float_or_none(gencov.intercept)
intercept_se_out = gencov.intercept_se

# Step 3: rg and delta-method SE

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can not find any reference to the delta method, where did you took it from?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants