feat: LDSC genetic correlation logic#1262
Conversation
project-defiant
left a comment
There was a problem hiding this comment.
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.
| n_blocks: int = 200, | ||
| intercept: float | None = None, | ||
| max_rows_for_collection: int = 15_000_000, | ||
| max_rows_for_collection: int = 20_000_000, |
There was a problem hiding this comment.
why changed to 20M?
| @@ -1,3 +1,4 @@ | |||
| # Deprecated: import from gentropy.method.ldsc instead. This file will be removed in a future release. | |||
There was a problem hiding this comment.
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
| # 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"), | ||
| ) |
There was a problem hiding this comment.
How do you know that t1 is correctly flipped ?
| 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 | ||
|
|
There was a problem hiding this comment.
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
flippedcolumn is redundant - You should definately persist and force the compute before flipping. This will speed up.
There was a problem hiding this comment.
I think this is not a part of genetic correlation ?
| beta1: np.ndarray, | ||
| se1: np.ndarray, | ||
| N1: np.ndarray, | ||
| beta2: np.ndarray, | ||
| se2: np.ndarray, |
There was a problem hiding this comment.
Instead of passing 4 x float array (N,1) pass 2 with Zscores. Will reduce the space complexity.
| 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) |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
I can not find any reference to the delta method, where did you took it from?
✨ 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
devbranch?make test)?uv run pre-commit run --all-files)?