From 568c599410e2bae461530da6b80bcb0b15739a53 Mon Sep 17 00:00:00 2001 From: Jayoung Ryu Date: Thu, 9 Nov 2023 11:00:28 -0500 Subject: [PATCH 01/27] initial commit for proliferation screen --- README.md | 4 +- bean/model/model.py | 1 - bean/model/readwrite.py | 39 ++-- bean/model/utils.py | 346 ------------------------------- bean/preprocessing/data_class.py | 345 +++++++++++++++++++++++------- bean/preprocessing/utils.py | 30 ++- bin/bean-run | 124 ++--------- 7 files changed, 345 insertions(+), 544 deletions(-) diff --git a/README.md b/README.md index 06a0fa0..ef0364a 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ bean-filter my_sorting_screen.h5ad \ ## `bean-run`: Quantify variant effects BEAN uses Bayesian network to incorporate gRNA editing outcome to provide posterior estimate of variant phenotype. The Bayesian network reflects data generation process. Briefly, -1. Cellular phenotype is modeled as the Gaussian mixture distribution of wild-type phenotype and variant phenotype. +1. Cellular phenotype (either for cells are sorted upon for sorting screen, or log(proliferation rate)) is modeled as the Gaussian mixture distribution of wild-type phenotype and variant phenotype. 2. The weight of the mixture components are inferred from the reporter editing outcome and the chromatin accessibility of the loci. 3. Cells with each gRNA, formulated as the mixture distribution, is sorted by the phenotypic quantile to produce the gRNA counts. @@ -258,7 +258,7 @@ For the full detail, see the method section of the [BEAN manuscript](https://www

```bash -bean-run variant[tiling] my_sorting_screen_filtered.h5ad \ +bean-run sorting[survival] variant[tiling] my_sorting_screen_filtered.h5ad \ [--uniform-edit, --scale-by-acc [--acc-bw-path accessibility_signal.bw, --acc-col accessibility]] \ -o output_prefix/ \ --fit-negctrl diff --git a/bean/model/model.py b/bean/model/model.py index a5c3b66..c6be6e9 100644 --- a/bean/model/model.py +++ b/bean/model/model.py @@ -91,7 +91,6 @@ def NormalModel( obs=data.X_masked.permute(0, 2, 1), ) if use_bcmatch: - print(f"Use_bcmatch:{use_bcmatch}") a_bcmatch = get_alpha( expected_guide_p, data.size_factor_bcmatch, diff --git a/bean/model/readwrite.py b/bean/model/readwrite.py index fc34d11..b41de39 100644 --- a/bean/model/readwrite.py +++ b/bean/model/readwrite.py @@ -57,41 +57,50 @@ def write_result_table( adjust_confidence_negatives: np.ndarray = None, guide_index: Optional[Sequence[str]] = None, guide_acc: Optional[Sequence] = None, + sd_is_fitted: bool = True, return_result: bool = False, ) -> Union[pd.DataFrame, None]: """Combine target information and scores to write result table to a csv file or return it.""" if param_hist_dict["params"]["mu_loc"].dim() == 2: mu = param_hist_dict["params"]["mu_loc"].detach()[:, 0].cpu().numpy() mu_sd = param_hist_dict["params"]["mu_scale"].detach()[:, 0].cpu().numpy() - sd = param_hist_dict["params"]["sd_loc"].detach().exp()[:, 0].cpu().numpy() + if sd_is_fitted: + sd = param_hist_dict["params"]["sd_loc"].detach().exp()[:, 0].cpu().numpy() elif param_hist_dict["params"]["mu_loc"].dim() == 1: mu = param_hist_dict["params"]["mu_loc"].detach().cpu().numpy() mu_sd = param_hist_dict["params"]["mu_scale"].detach().cpu().numpy() - sd = param_hist_dict["params"]["sd_loc"].detach().exp().cpu().numpy() + if sd_is_fitted: + sd = param_hist_dict["params"]["sd_loc"].detach().exp().cpu().numpy() else: raise ValueError( f'`mu_loc` has invalid shape of {param_hist_dict["params"]["mu_loc"].shape}' ) - fit_df = pd.DataFrame( - { - "mu": mu, - "mu_sd": mu_sd, - "mu_z": mu / mu_sd, - "sd": sd, - } - ) + param_dict = { + "mu": mu, + "mu_sd": mu_sd, + "mu_z": mu / mu_sd, + } + if sd_is_fitted: + param_dict["sd"] = sd + fit_df = pd.DataFrame(param_dict) fit_df["novl"] = get_novl(fit_df, "mu", "mu_sd") if "negctrl" in param_hist_dict.keys(): print("Normalizing with common negative control distribution") mu0 = param_hist_dict["negctrl"]["params"]["mu_loc"].detach().cpu().numpy() - sd0 = ( - param_hist_dict["negctrl"]["params"]["sd_loc"].detach().exp().cpu().numpy() - ) - print(f"Fitted mu0={mu0}, sd0={sd0}.") + if sd_is_fitted: + sd0 = ( + param_hist_dict["negctrl"]["params"]["sd_loc"] + .detach() + .exp() + .cpu() + .numpy() + ) + print(f"Fitted mu0={mu0}" + (f", sd0={sd0}." if sd_is_fitted else "")) fit_df["mu_scaled"] = (mu - mu0) / sd0 fit_df["mu_sd_scaled"] = mu_sd / sd0 fit_df["mu_z_scaled"] = fit_df.mu_scaled / fit_df.mu_sd_scaled - fit_df["sd_scaled"] = sd / sd0 + if sd_is_fitted: + fit_df["sd_scaled"] = sd / sd0 fit_df["novl_scaled"] = get_novl(fit_df, "mu_scaled", "mu_sd_scaled") fit_df = pd.concat( diff --git a/bean/model/utils.py b/bean/model/utils.py index 10f059c..7767232 100644 --- a/bean/model/utils.py +++ b/bean/model/utils.py @@ -1,355 +1,9 @@ -import os -import sys -import argparse -from tqdm import tqdm -import logging -import pickle as pkl -import pandas as pd import torch import torch.distributions as tdist import pyro import pyro.distributions as dist import pyro.distributions.constraints as constraints -logging.basicConfig( - level=logging.INFO, - format="%(levelname)-5s @ %(asctime)s:\n\t %(message)s \n", - datefmt="%a, %d %b %Y %H:%M:%S", - stream=sys.stderr, - filemode="w", -) -error = logging.critical -warn = logging.warning -debug = logging.debug -info = logging.info -pyro.set_rng_seed(101) - - -def run_inference( - model, guide, data, initial_lr=0.01, gamma=0.1, num_steps=2000, autoguide=False -): - pyro.clear_param_store() - lrd = gamma ** (1 / num_steps) - svi = pyro.infer.SVI( - model=model, - guide=guide, - optim=pyro.optim.ClippedAdam({"lr": initial_lr, "lrd": lrd}), - loss=pyro.infer.Trace_ELBO(), - ) - losses = [] - try: - for t in tqdm(range(num_steps)): - loss = svi.step(data) - if t % 100 == 0: - print(f"loss {loss} @ iter {t}") - losses.append(loss) - except ValueError as exc: - error( - "Error occurred during fitting. Saving temporary output at tmp_result.pkl." - ) - with open("tmp_result.pkl", "wb") as handle: - pkl.dump({"param": pyro.get_param_store()}, handle) - - raise ValueError( - f"Fitting halted for command: {' '.join(sys.argv)} with following error: \n {exc}" - ) - return { - "loss": losses, - "params": pyro.get_param_store(), - } - - -def _get_guide_target_info(bdata, args, cols_include=[]): - guide_info = bdata.guides.copy() - target_info = ( - guide_info[ - [args.target_col] - + [ - col - for col in guide_info.columns - if ( - ( - (col.startswith("target_")) - and len(guide_info[[args.target_col, col]].drop_duplicates()) - == len(guide_info[args.target_col].unique()) - ) - or col in cols_include - ) - and col != args.target_col - ] - ] - .drop_duplicates() - .set_index(args.target_col, drop=True) - ) - if "edit_rate" in guide_info.columns.tolist(): - edit_rate_info = ( - guide_info[[args.target_col, "edit_rate"]] - .groupby(args.target_col, sort=False) - .agg({"edit_rate": ["mean", "std"]}) - ) - edit_rate_info.columns = edit_rate_info.columns.get_level_values(1) - edit_rate_info = edit_rate_info.rename( - columns={"mean": "edit_rate_mean", "std": "edit_rate_std"} - ) - target_info = target_info.join(edit_rate_info) - return target_info - - -def none_or_str(value): - if value == "None": - return None - return value - - -def parse_args(): - print( - r""" - _ _ - / \ '\ - | \ \ _ _ _ _ _ _ - \ \ | | '_| || | ' \ - `.__|/ |_| \_,_|_||_| - """ - ) - print("bean-run: Run model to identify targeted variants and their impact.") - parser = argparse.ArgumentParser(description="Run model on data.") - - parser.add_argument( - "mode", - type=str, - help="[variant, tiling]- Screen type whether to run variant or tiling screen model.", - ) - parser.add_argument("bdata_path", type=str, help="Path of an ReporterScreen object") - parser.add_argument( - "--rep-pi", - "-r", - action="store_true", - default=False, - help="Fit replicate specific scaling factor. Recommended to set as True if you expect variable editing activity across biological replicates.", - ) - parser.add_argument( - "--uniform-edit", - "-p", - action="store_true", - default=False, - help="Assume uniform editing rate for all guides.", - ) - parser.add_argument( - "--scale-by-acc", - action="store_true", - default=False, - help="Scale guide editing efficiency by the target loci accessibility", - ) - parser.add_argument( - "--acc-bw-path", - type=str, - default=None, - help="Accessibility .bigWig file to be used to assign accessibility of guides.", - ) - parser.add_argument( - "--acc-col", - type=str, - default=None, - help="Column name in bdata.guides that specify raw ATAC-seq signal.", - ) - parser.add_argument( - "--const-pi", - default=False, - action="store_true", - help="Use constant pi provided in --guide-activity-col (instead of fitting from reporter data)", - ) - parser.add_argument( - "--shrink-alpha", - default=False, - action="store_true", - help="Instead of using the trend-fitted alpha values, use estimated alpha values for each gRNA that are shrunk towards the fitted trend.", - ) - parser.add_argument( - "--condition-col", - default="bin", - type=str, - help="Column key in `bdata.samples` that describes experimental condition.", - ) - parser.add_argument( - "--control-condition-label", - default="bulk", - type=str, - help="Value in `bdata.samples[condition_col]` that indicates control experimental condition.", - ) - parser.add_argument( - "--replicate-col", - default="rep", - type=str, - help="Column key in `bdata.samples` that describes experimental replicates.", - ) - parser.add_argument( - "--target-col", - default="target", - type=str, - help="Column key in `bdata.guides` that describes the target element of each guide.", - ) - parser.add_argument( - "--guide-activity-col", - "-a", - type=str, - default=None, - help="Column in ReporterScreen.guides DataFrame showing the editing rate estimated via external tools", - ) - parser.add_argument( - "--outdir", - "-o", - default=".", - type=str, - help="Directory to save the run result.", - ) - parser.add_argument( - "--result-suffix", - default="", - type=str, - help="Suffix of the output files", - ) - parser.add_argument( - "--sorting-bin-upper-quantile-col", - "-uq", - help="Column name with upper quantile values of each sorting bin in [Reporter]Screen.samples (or AnnData.var)", - default="upper_quantile", - ) - parser.add_argument( - "--sorting-bin-lower-quantile-col", - "-lq", - help="Column name with lower quantile values of each sorting bin in [Reporter]Screen.samples (or AnnData var)", - default="lower_quantile", - ) - parser.add_argument("--cuda", action="store_true", default=False, help="run on GPU") - parser.add_argument( - "--sample-mask-col", - type=str, - default=None, - help="Name of the column indicating the sample mask in [Reporter]Screen.samples (or AnnData.var). Sample is ignored if the value in this column is 0. This can be used to mask out low-quality samples.", - ) - parser.add_argument( - "--fit-negctrl", - action="store_true", - default=False, - help="Fit the shared negative control distribution to normalize the fitted parameters", - ) - parser.add_argument( - "--negctrl-col", - type=str, - default="target_group", - help="Column in bdata.obs specifying if a guide is negative control. If the `bdata.guides[negctrl_col].lower() == negctrl_col_value`, it is treated as negative control guide.", - ) - parser.add_argument( - "--negctrl-col-value", - type=str, - default="negctrl", - help="Column value in bdata.guides specifying if a guide is negative control. If the `bdata.guides[negctrl_col].lower() == negctrl_col_value`, it is treated as negative control guide.", - ) - parser.add_argument( - "--repguide-mask", - type=none_or_str, - default="repguide_mask", - help="n_replicate x n_guide mask to mask the outlier guides. screen.uns[repguide_mask] will be used.", - ) - parser.add_argument( - "--device", - type=str, - default=None, - help="Optionally use GPU if provided valid GPU device name (ex. cuda:0)", - ) - parser.add_argument( - "--ignore-bcmatch", - action="store_true", - default=False, - help="If provided, even if the screen object has .X_bcmatch, ignore the count when fitting.", - ) - parser.add_argument( - "--allele-df-key", - type=str, - default=None, - help="screen.uns[allele_df_key] will be used as the allele count.", - ) - parser.add_argument( - "--splice-site-path", - type=str, - default=None, - help="Path to splicing site", - ) - parser.add_argument( - "--control-guide-tag", - type=none_or_str, - default="CONTROL", - help="If this string is in guide name, treat each guide separately not to mix the position. Used for negative controls.", - ) - parser.add_argument( - "--dont-fit-noise", # TODO: add check args - action="store_true", - ) - parser.add_argument( - "--dont-adjust-confidence-by-negative-control", - action="store_true", - help="Adjust confidence by negative controls. For variant mode, this uses negative control variants. For tiling mode, adjusts confidence by synonymous edits.", - ) - parser.add_argument( - "--load-existing", # TODO: add check args - action="store_true", - help="Load existing .pkl file if present.", - ) - - return parser.parse_args() - - -def check_args(args, bdata): - args.adjust_confidence_by_negative_control = ( - not args.dont_adjust_confidence_by_negative_control - ) - if args.scale_by_acc: - if args.acc_col is None and args.acc_bw_path is None: - raise ValueError( - "--scale-by-acc not accompanied by --acc-col nor --acc-bw-path to use. Pass either one." - ) - elif args.acc_col is not None and args.acc_bw_path is not None: - warn( - "Both --acc-col and --acc-bw-path is specified. --acc-bw-path is ignored." - ) - args.acc_bw_path = None - if args.outdir is None: - args.outdir = os.path.dirname(args.bdata_path) - if args.mode == "variant": - pass - elif args.mode == "tiling": - if args.allele_df_key is None: - raise ValueError( - "--allele-df-key not provided for tiling screen. Feed in the key then allele counts in screen.uns[allele_df_key] will be used." - ) - else: - raise ValueError( - "Invalid mode provided. Select either 'variant' or 'tiling'." - ) # TODO: change this into discrete modes via argparse - if args.fit_negctrl: - n_negctrl = ( - bdata.guides[args.negctrl_col].map(lambda s: s.lower()) - == args.negctrl_col_value.lower() - ).sum() - if not n_negctrl >= 20: - raise ValueError( - f"Not enough negative control guide in the input data: {n_negctrl}. Check your input arguments." - ) - if args.repguide_mask is not None and args.repguide_mask not in bdata.uns.keys(): - bdata.uns[args.repguide_mask] = pd.DataFrame( - index=bdata.guides.index, columns=bdata.samples[args.replicate_col].unique() - ).fillna(1) - warn( - f"{args.bdata_path} does not have replicate x guide outlier mask. All guides are included in analysis." - ) - if args.sample_mask_col is not None: - if args.sample_mask_col not in bdata.samples.columns.tolist(): - raise ValueError( - f"{args.bdata_path} does not have specified sample mask column {args.sample_mask_col} in .samples" - ) - - return args, bdata - def get_alpha( expected_guide_p, size_factor, sample_mask, a0, epsilon=1e-5, normalize_by_a0=True diff --git a/bean/preprocessing/data_class.py b/bean/preprocessing/data_class.py index ea4953e..e3c518d 100644 --- a/bean/preprocessing/data_class.py +++ b/bean/preprocessing/data_class.py @@ -1,8 +1,8 @@ import sys import abc +import logging from dataclasses import dataclass from typing import Dict, Tuple -import logging from xmlrpc.client import Boolean from copy import deepcopy import torch @@ -13,7 +13,6 @@ from .get_pi_alpha0 import get_fitted_alpha0 as get_fitted_pi_alpha0 from .get_pi_alpha0 import get_pred_alpha0 as get_pred_pi_alpha0 from .utils import ( - Alias, get_accessibility_guides, get_edit_to_index_dict, _assign_rep_ids_and_sort, @@ -47,8 +46,13 @@ def __init__( device: str = None, replicate_column: str = "rep", pi_popt: Tuple[float] = None, + control_can_be_selected: bool = False, **kwargs, ): + """ + Args + control_can_be_selected: If True, screen.samples[condition_column] == control_condition can also be included in effect size inference if its condition column is not NA (Currently only suppoted for prolifertion screens). + """ # TODO: remove replicate with too small number of (ex. only 1) sorting bin self.condition_column = condition_column self.device = device @@ -66,9 +70,12 @@ def __init__( ) self.screen = screen - self.screen_selected = screen[ - :, screen.samples[condition_column] != control_condition - ] + if not control_can_be_selected: + self.screen_selected = screen[ + :, screen.samples[condition_column] != control_condition + ] + else: + self.screen_selected = screen[:, ~screen.samples[condition_column].isnull()] self.n_condits = len( self.screen_selected.var[condition_column].unique() @@ -178,7 +185,16 @@ def __getitem__(self, guide_idx): def transform_data(self, X, n_bins=None): if n_bins is None: n_bins = self.n_condits - x = torch.as_tensor(X).T.reshape((self.n_reps, n_bins, self.n_guides)).float() + try: + x = ( + torch.as_tensor(X) + .T.reshape((self.n_reps, n_bins, self.n_guides)) + .float() + ) + except RuntimeError: + print((self.n_reps, n_bins, self.n_guides)) + print(X.shape) + exit(1) if self.device is not None: x = x.cuda() return x @@ -668,7 +684,7 @@ def transform_allele(self, adata, reindexed_df): allele_tensor = torch.empty( (self.n_reps, self.n_condits, self.n_guides, self.n_max_alleles), ) - if not self.device is None: + if self.device is not None: allele_tensor = allele_tensor.cuda() for i in range(self.n_reps): for j in range(self.n_condits): @@ -710,7 +726,7 @@ def transform_allele(self, adata, reindexed_df): try: assert (allele_tensor >= 0).all(), allele_tensor[allele_tensor < 0] - except: + except AssertionError: print("Allele tensor doesn't match condit_allele_df") return (allele_tensor, reindexed_df) return allele_tensor @@ -724,7 +740,7 @@ def transform_allele_control(self, adata, reindexed_df): allele_tensor = torch.empty( (self.n_reps, 1, self.n_guides, self.n_max_alleles), ) - if not self.device is None: + if self.device is not None: allele_tensor = allele_tensor.cuda() for i in range(self.n_reps): condit_idx = np.where(adata.samples.rep_id == i)[0] @@ -761,14 +777,10 @@ def transform_allele_control(self, adata, reindexed_df): self.n_guides, self.n_max_alleles, ) - try: - allele_tensor[i, 0, :, :] = torch.as_tensor(condit_allele_df.to_numpy()) - except: - print("Allele tensor doesn't match condit_allele_df") - return (allele_tensor, torch.as_tensor(condit_allele_df.to_numpy())) + allele_tensor[i, 0, :, :] = torch.as_tensor(condit_allele_df.to_numpy()) try: assert (allele_tensor >= 0).all(), allele_tensor[allele_tensor < 0] - except: + except AssertionError: print("Negative values in allele_tensor") return (allele_tensor, reindexed_df) return allele_tensor @@ -796,48 +808,6 @@ def get_allele_mask( mask[i, j + 1] = 1 return mask.bool() - def get_allele_to_edit_tensor( - self, - adata, - edits_to_index: Dict[str, int], - guide_allele_id_to_allele_df: pd.DataFrame, - ): - """ - Convert (guide, allele_id_for_guide) -> allele DataFrame into the tensor with shape (n_guides, n_max_alleles_per_guide, n_edits) tensor. - ----- - Arguments - edits_to_index (dict) -- Dictionary from edit (str) to unique index (int) - guide_allele_id_to_allele_df (pd.DataFrame) -- pd.DataFrame of (guide(str), allele_id_for_guide(int)) -> CodingNoncodingAllele - ----- - Returns - allele_edit_assignment (torch.Tensor) -- Binary tensor of shape (n_guides, n_max_alleles_per_guide, n_edits). - allele_edit_assignment(i, j, k) is 1 if jth allele of ith guide has kth edit. - """ - guide_allele_id_to_allele_df[ - "edits" - ] = guide_allele_id_to_allele_df.aa_allele.map( - lambda a: list(a.aa_allele.edits) + list(a.nt_allele.edits) - ) - guide_allele_id_to_allele_df = guide_allele_id_to_allele_df.reset_index() - guide_allele_id_to_allele_df[ - "edit_idx" - ] = guide_allele_id_to_allele_df.edits.map( - lambda es: [edits_to_index[e.get_abs_edit()] for e in es] - ) - guide_allele_id_to_edit_df = guide_allele_id_to_allele_df[ - ["guide", "allele_id_for_guide", "edit_idx"] - ].set_index(["guide", "allele_id_for_guide"]) - guide_allele_id_to_edit_df = guide_allele_id_to_edit_df.unstack( - level=1, fill_value=[] - ).reindex(adata.guides.index, fill_value=[]) - allele_edit_assignment = torch.zeros( - (len(adata.guides), self.n_max_alleles - 1, len(edits_to_index.keys())) - ) - for i in range(len(guide_allele_id_to_edit_df)): - for j in range(len(guide_allele_id_to_edit_df.columns)): - allele_edit_assignment[i, j, guide_allele_id_to_edit_df.iloc[i, j]] = 1 - return allele_edit_assignment - @dataclass class SortingScreenData(ScreenData): @@ -909,7 +879,7 @@ def _pre_init( == len(self.screen.samples[self.replicate_column].unique()) ).all(): raise ValueError( - "Not all replicate share same quantile bin definition. If you have missing bin data, add the sample and add 'mask' column in 'screen.samples'." + "Not all replicate share same quantile bin definition. If you have missing bin data, add the sample and add 'mask' column in 'screen.samples' or run `bean-qc` that automatically handles this." ) sorting_bins = self.screen_selected.samples.sort_values( [upper_quantile_column, lower_quantile_column] @@ -952,12 +922,14 @@ def __init__( repguide_mask: str = None, sample_mask_column: str = None, shrink_alpha: bool = False, - condition_column: str = "time", + condition_column: str = "condition", control_condition: str = "bulk", - accessibility_col: str = None, - accessibility_bw_path: str = None, + control_can_be_selected=True, + time_column: str = "time", + replicate_column: str = "rep", **kwargs, ): + self._pre_init(condition_column) super().__init__( screen=screen, repguide_mask=repguide_mask, @@ -965,14 +937,59 @@ def __init__( shrink_alpha=shrink_alpha, condition_column=condition_column, control_condition=control_condition, - accessibility_col=accessibility_col, - accessibility_bw_path=accessibility_bw_path, + control_can_be_selected=control_can_be_selected, **kwargs, ) + self._post_init() + + def _pre_init(self, time_column: str, condition_column: str): + self.time_column = time_column + if not np.issubdtype(self.screen.samples[time_column].dtype, np.number): + raise ValueError( + f"Invalid timepoint value({self.screen.samples[time_column]}) in screen.samples[{time_column}]: check input." + ) + + if not ( + self.screen.samples.groupby(condition_column).size() + == len(self.screen.samples[self.replicate_column].unique()) + ).all(): + raise ValueError( + f"Not all replicate share same timepoint definition. If you have missing bin data, add the sample and add 'mask' column in 'screen.samples', or run `bean-qc` that automatically handles this. \n{self.screen.samples}" + ) + + def _post_init( + self, + ): self.timepoints = torch.as_tensor( - self.screen.samples[condition_column].unique() + self.screen_selected.samples[self.time_column].unique() + ) + self.n_timepoints = self.n_condits + timepoints = self.screen_selected.samples.sort_values(self.time_column)[ + self.time_column + ].drop_duplicates() + if timepoints.isnull().any(): + raise ValueError( + f"NaN values in time points provided in input: {self.screen_selected.samples[self.time_column]}" + ) + for j, time in enumerate(timepoints): + self.screen_selected.samples.loc[ + self.screen_selected.samples[self.time_column] == time, + f"{self.time_column}_id", + ] = j + self.screen.samples[f"{self.time_column}_id"] = -1 + self.screen.samples.loc[ + self.screen_selected.samples.index, f"{self.time_column}_id" + ] = self.screen_selected.samples[f"{self.time_column}_id"] + self.screen = _assign_rep_ids_and_sort( + self.screen, self.replicate_column, self.time_column + ) + self.screen_selected = _assign_rep_ids_and_sort( + self.screen_selected, self.replicate_column, self.time_column + ) + self.screen_control = _assign_rep_ids_and_sort( + self.screen_control, + self.replicate_column, ) - self.timepoints = Alias("n_condits") @dataclass @@ -985,7 +1002,7 @@ def __init__( pi_popt: Tuple[float] = None, impute_pi_popt: bool = False, shrink_alpha: bool = False, - condition_column: str = "time", + condition_column: str = "condition", control_condition: str = "bulk", accessibility_col: str = None, accessibility_bw_path: str = None, @@ -1036,8 +1053,8 @@ def __init__( screen, *args, sample_mask_column=sample_mask_column, - replicate_column="rep", - condition_column="bin", + replicate_column=replicate_column, + condition_column=condition_column, shrink_alpha=shrink_alpha, **kwargs, ) @@ -1110,8 +1127,8 @@ def __init__( screen, *args, sample_mask_column=sample_mask_column, - replicate_column="rep", - condition_column="bin", + replicate_column=replicate_column, + condition_column=condition_column, shrink_alpha=shrink_alpha, **kwargs, ) @@ -1159,8 +1176,8 @@ def __init__( screen, *args, sample_mask_column=sample_mask_column, - replicate_column="rep", - condition_column="bin", + replicate_column=replicate_column, + condition_column=condition_column, shrink_alpha=shrink_alpha, **kwargs, ) @@ -1186,6 +1203,188 @@ def __init__( ) +@dataclass +class VariantSurvivalScreenData(VariantScreenData, SurvivalScreenData): + def __init__( + self, + screen, + *args, + replicate_column="rep", + condition_column="condition", + time_column="time", + control_can_be_selected=True, + target_col="target", + sample_mask_column="mask", + shrink_alpha: bool = False, + use_bcmatch=False, + **kwargs, + ): + ScreenData.__init__( + self, + screen, + *args, + sample_mask_column=sample_mask_column, + replicate_column=replicate_column, + condition_column=condition_column, + time_column=time_column, + shrink_alpha=shrink_alpha, + control_can_be_selected=control_can_be_selected, + **kwargs, + ) + SurvivalScreenData._pre_init(self, time_column, condition_column) + ScreenData._post_init(self) + SurvivalScreenData._post_init(self) + VariantScreenData._post_init(self, target_col) + if use_bcmatch: + self.set_bcmatch( + screen, + ) + + def set_bcmatch(self, screen): + screen.samples["size_factor_bcmatch"] = self.get_size_factor( + screen.layers["X_bcmatch"] + ) + self.screen_selected.samples["size_factor_bcmatch"] = screen.samples.loc[ + self.screen_selected.samples.index, "size_factor_bcmatch" + ] + self.screen_control.samples["size_factor_bcmatch"] = screen.samples.loc[ + self.screen_control.samples.index, "size_factor_bcmatch" + ] + self.X_bcmatch = self.transform_data(self.screen_selected.layers["X_bcmatch"]) + self.X_bcmatch_masked = self.X_bcmatch * self.sample_mask[:, :, None] + self.X_bcmatch_control = self.transform_data( + self.screen_control.layers["X_bcmatch"], 1 + ) + self.X_bcmatch_control_masked = ( + self.X_bcmatch_control * self.bulk_sample_mask[:, None, None] + ) + self.size_factor_bcmatch = torch.as_tensor( + self.screen_selected.samples["size_factor_bcmatch"].to_numpy() + ).reshape(self.n_reps, self.n_condits) + self.size_factor_bcmatch_control = torch.as_tensor( + self.screen_control.samples["size_factor_bcmatch"].to_numpy() + ).reshape(self.n_reps, 1) + a0_bcmatch = get_pred_alpha0( + self.X_bcmatch.clone().cpu(), + self.size_factor_bcmatch.clone().cpu(), + self.popt, + self.sample_mask.cpu(), + ) + self.a0_bcmatch = torch.as_tensor(a0_bcmatch) + + @dataclass class VariantSurvivalReporterScreenData(VariantReporterScreenData, SurvivalScreenData): - pass + def __init__( + self, + screen, + *args, + replicate_column="rep", + condition_column="condition", + time_column="time", + control_can_be_selected=True, + target_col="target", + sample_mask_column="mask", + use_const_pi: bool = False, + impute_pi_popt: bool = False, + pi_prior_count: int = 10, + shrink_alpha: bool = False, + pi_popt: Tuple[float] = None, + **kwargs, + ): + ScreenData.__init__( + self, + screen, + *args, + sample_mask_column=sample_mask_column, + replicate_column=replicate_column, + condition_column=condition_column, + time_column=time_column, + shrink_alpha=shrink_alpha, + control_can_be_selected=control_can_be_selected, + **kwargs, + ) + SurvivalScreenData._pre_init(self, time_column, condition_column) + ScreenData._post_init(self) + SurvivalScreenData._post_init(self) + VariantScreenData._post_init(self, target_col) + ReporterScreenData._post_init( + self, + screen, + use_const_pi, + impute_pi_popt, + pi_prior_count, + shrink_alpha, + pi_popt, + ) + + +@dataclass +class TilingSurvivalReporterScreenData(TilingReporterScreenData, SurvivalScreenData): + def __init__( + self, + screen, + *args, + replicate_column="rep", + condition_column="condition", + time_column="time", + control_can_be_selected=True, + sample_mask_column="mask", + use_const_pi: bool = False, + impute_pi_popt: bool = False, + pi_prior_count: int = 10, + shrink_alpha: bool = False, + pi_popt: Tuple[float] = None, + allele_df_key: str = None, + allele_col: str = None, + control_guide_tag: str = None, + **kwargs, + ): + ScreenData.__init__( + self, + screen, + *args, + sample_mask_column=sample_mask_column, + replicate_column=replicate_column, + condition_column=condition_column, + time_column=time_column, + shrink_alpha=shrink_alpha, + control_can_be_selected=control_can_be_selected, + **kwargs, + ) + SurvivalScreenData._pre_init(self, time_column, condition_column) + ScreenData._post_init(self) + SurvivalScreenData._post_init(self) + TilingReporterScreenData._post_init( + self, + allele_df_key=allele_df_key, + control_guide_tag=control_guide_tag, + ) + ReporterScreenData._post_init( + self, + screen, + use_const_pi, + impute_pi_popt, + pi_prior_count, + shrink_alpha, + pi_popt, + ) + + +DATACLASS_DICT = { + "sorting": { + "Normal": VariantSortingScreenData, + "MixtureNormal": VariantSortingReporterScreenData, + "MixtureNormal+Acc": VariantSortingReporterScreenData, + "MixtureNormalConstPi": VariantSortingScreenData, + "MultiMixtureNormal": TilingSortingReporterScreenData, + "MultiMixtureNormal+Acc": TilingSortingReporterScreenData, + }, + "survival": { + "Normal": VariantSurvivalScreenData, + "MixtureNormal": VariantSurvivalReporterScreenData, + "MixtureNormal+Acc": VariantSurvivalReporterScreenData, + "MultiMixtureNormal": TilingSurvivalReporterScreenData, + "MultiMixtureNormal+Acc": TilingSurvivalReporterScreenData, + }, +} diff --git a/bean/preprocessing/utils.py b/bean/preprocessing/utils.py index 1972fa5..ebea9ab 100644 --- a/bean/preprocessing/utils.py +++ b/bean/preprocessing/utils.py @@ -3,8 +3,8 @@ import numpy as np import pyBigWig import pandas as pd -import anndata as ad import bean as be +from bean.qc.guide_qc import filter_no_info_target class Alias: @@ -21,6 +21,34 @@ def __set__(self, obj, value): setattr(obj, self.source_name, value) +def prepare_bdata(bdata: be.ReporterScreen, args, warn, prefix: str): + """Utility function for formatting bdata for bean-run""" + bdata = bdata.copy() + bdata.samples[args.replicate_col] = bdata.samples[args.replicate_col].astype( + "category" + ) + bdata.guides = bdata.guides.loc[:, ~bdata.guides.columns.duplicated()].copy() + if args.library_design == "variant": + if bdata.guides[args.target_col].isnull().any(): + raise ValueError( + f"Some target column (bdata.guides[{args.target_col}]) value is null. Check your input file." + ) + bdata = bdata[bdata.guides[args.target_col].argsort(), :] + n_no_support_targets, bdata = filter_no_info_target( + bdata, + condit_col=args.condition_col, + control_condition=args.control_condition_label, + target_col=args.target_col, + write_no_support_targets=True, + no_support_target_write_path=f"{prefix}/no_support_targets.csv", + ) + if n_no_support_targets > 0: + warn( + f"Ignoring {n_no_support_targets} targets with 0 gRNA counts across all non-control samples. Ignored targets are written in {prefix}/no_support_targets.csv." + ) + return bdata + + def _get_accessibility_single( pos: int, track, diff --git a/bin/bean-run b/bin/bean-run index 1b358ee..6a703a3 100644 --- a/bin/bean-run +++ b/bin/bean-run @@ -3,7 +3,6 @@ import os import sys import logging from copy import deepcopy -from functools import partial import numpy as np import pandas as pd @@ -13,25 +12,22 @@ import pyro.infer import pyro.optim import pickle as pkl -from bean.qc.guide_qc import filter_no_info_target import bean.model.model as m from bean.model.readwrite import write_result_table -from bean.preprocessing.data_class import ( - VariantSortingScreenData, - VariantSortingReporterScreenData, - TilingSortingReporterScreenData, -) +from bean.preprocessing.data_class import DATACLASS_DICT from bean.preprocessing.utils import ( + prepare_bdata, _obtain_effective_edit_rate, _obtain_n_guides_alleles_per_variant, ) import bean as be -from bean.model.utils import ( +from bean.model.run import ( run_inference, _get_guide_target_info, parse_args, check_args, + identify_model_guide, ) logging.basicConfig( @@ -47,73 +43,6 @@ debug = logging.debug info = logging.info pyro.set_rng_seed(101) -DATACLASS_DICT = { - "Normal": VariantSortingScreenData, - "MixtureNormal": VariantSortingReporterScreenData, - "_MixtureNormal+Acc": VariantSortingReporterScreenData, # TODO: old - "MixtureNormal+Acc": VariantSortingReporterScreenData, - "MixtureNormalConstPi": VariantSortingScreenData, - "MultiMixtureNormal": TilingSortingReporterScreenData, - "MultiMixtureNormal+Acc": TilingSortingReporterScreenData, -} - - -def identify_model_guide(args): - if args.mode == "tiling": - info("Using Mixture Normal model...") - return ( - f"MultiMixtureNormal{'+Acc' if args.scale_by_acc else ''}", - partial( - m.MultiMixtureNormalModel, - scale_by_accessibility=args.scale_by_acc, - use_bcmatch=(not args.ignore_bcmatch,), - ), - partial( - m.MultiMixtureNormalGuide, - scale_by_accessibility=args.scale_by_acc, - fit_noise=~args.dont_fit_noise, - ), - ) - if args.uniform_edit: - if args.guide_activity_col is not None: - raise ValueError( - "Can't use the guide activity column while constraining uniform edit." - ) - info("Using Normal model...") - return ( - "Normal", - partial(m.NormalModel, use_bcmatch=(not args.ignore_bcmatch)), - m.NormalGuide, - ) - elif args.const_pi: - if args.guide_activity_col is not None: - raise ValueError( - "--guide-activity-col to be used as constant pi is not provided." - ) - info("Using Mixture Normal model with constant weight ...") - return ( - "MixtureNormalConstPi", - partial(m.MixtureNormalConstPiModel, use_bcmatch=(not args.ignore_bcmatch)), - m.MixtureNormalGuide, - ) - else: - info( - f"Using Mixture Normal model {'with accessibility normalization' if args.scale_by_acc else ''}..." - ) - return ( - f"{'_' if args.dont_fit_noise else ''}MixtureNormal{'+Acc' if args.scale_by_acc else ''}", - partial( - m.MixtureNormalModel, - scale_by_accessibility=args.scale_by_acc, - use_bcmatch=(not args.ignore_bcmatch,), - ), - partial( - m.MixtureNormalGuide, - scale_by_accessibility=args.scale_by_acc, - fit_noise=(not args.dont_fit_noise), - ), - ) - def main(args, bdata): if args.cuda: @@ -128,32 +57,10 @@ def main(args, bdata): ) os.makedirs(prefix, exist_ok=True) model_label, model, guide = identify_model_guide(args) - guide_index = bdata.guides.index info("Done loading data. Preprocessing...") - bdata.samples[args.replicate_col] = bdata.samples[args.replicate_col].astype( - "category" - ) - bdata.guides = bdata.guides.loc[:, ~bdata.guides.columns.duplicated()].copy() - if args.mode == "variant": - if bdata.guides[args.target_col].isnull().any(): - raise ValueError( - f"Some target column (bdata.guides[{args.target_col}]) value is null. Check your input file." - ) - bdata = bdata[bdata.guides[args.target_col].argsort(), :] - if args.mode == "variant": - n_no_support_targets, bdata = filter_no_info_target( - bdata, - condit_col=args.condition_col, - control_condition=args.control_condition_label, - target_col=args.target_col, - write_no_support_targets=True, - no_support_target_write_path=f"{prefix}/no_support_targets.csv", - ) - if n_no_support_targets > 0: - warn( - f"Ignoring {n_no_support_targets} targets with 0 gRNA counts across all non-control samples. Ignored targets are written in {prefix}/no_support_targets.csv." - ) - ndata = DATACLASS_DICT[model_label]( + bdata = prepare_bdata(bdata, args, warn, prefix) + guide_index = bdata.guides.index.copy() + ndata = DATACLASS_DICT[args.selection][model_label]( screen=bdata, device=args.device, repguide_mask=args.repguide_mask, @@ -162,7 +69,9 @@ def main(args, bdata): accessibility_bw_path=args.acc_bw_path, use_const_pi=args.const_pi, condition_column=args.condition_col, + time_column=args.time_col, control_condition=args.control_condition_label, + control_can_be_selected=args.include_control_condition_for_inference, allele_df_key=args.allele_df_key, control_guide_tag=args.control_guide_tag, target_col=args.target_col, @@ -171,12 +80,12 @@ def main(args, bdata): use_bcmatch=(not args.ignore_bcmatch), ) adj_negctrl_idx = None - if args.mode == "variant": + if args.library_design == "variant": if not args.uniform_edit: - if "edit_rate" not in bdata.guides.columns: - bdata.get_edit_from_allele() - bdata.get_edit_mat_from_uns(rel_pos_is_reporter=True) - bdata.get_guide_edit_rate() + if "edit_rate" not in ndata.screen.guides.columns: + ndata.screen.get_edit_from_allele() + ndata.screen.get_edit_mat_from_uns(rel_pos_is_reporter=True) + ndata.screen.get_guide_edit_rate() target_info_df = _get_guide_target_info( ndata.screen, args, cols_include=[args.negctrl_col] ) @@ -209,7 +118,9 @@ def main(args, bdata): with open(f"{prefix}/{model_label}.result.pkl", "rb") as handle: param_history_dict = pkl.load(handle) else: - param_history_dict = deepcopy(run_inference(model, guide, ndata)) + param_history_dict = deepcopy( + run_inference(model, guide, ndata, num_steps=args.n_iter) + ) if args.fit_negctrl: negctrl_model = m.ControlNormalModel negctrl_guide = m.ControlNormalGuide @@ -249,6 +160,7 @@ def main(args, bdata): else None, adjust_confidence_by_negative_control=args.adjust_confidence_by_negative_control, adjust_confidence_negatives=adj_negctrl_idx, + sd_is_fitted=(args.selection == "sorting"), ) info("Done!") From 442d69170efe08f4c461c47267c5552cb4a2c3a9 Mon Sep 17 00:00:00 2001 From: Jayoung Ryu Date: Wed, 22 Nov 2023 15:37:48 -0500 Subject: [PATCH 02/27] allow non-'bulk' condition to calculate guide-level editing rate on --- README.md | 1 + bean/qc/utils.py | 6 ++++++ bin/bean-qc | 1 + bin/bean-run | 5 ++++- notebooks/sample_quality_report.ipynb | 9 ++++++--- 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f91da6b..1e68850 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,7 @@ Above command produces * `--posctrl-val` (default: `PosCtrl`): Value in .h5ad.guides[`posctrl_col`] that specifies guide will be used as the positive control in calculating log fold change. * `--lfc-thres` (default: `0.1`): Positive guides' correlation threshold to filter out. * `--lfc-conds` (default: `"top,bot"`): Values in of column in `ReporterScreen.samples[condition_label]` for LFC will be calculated between, delimited by comma +* `--ctrl-cond` (default: `"bulk"`): Value in of column in `ReporterScreen.samples[condition_label]` where guide-level editing rate to be calculated * `--recalculate-edits` (default: `False`): Even when `ReporterScreen.layers['edit_count']` exists, recalculate the edit counts from `ReporterScreen.uns['allele_count']`."

diff --git a/bean/qc/utils.py b/bean/qc/utils.py index 5090f2f..35cd47e 100644 --- a/bean/qc/utils.py +++ b/bean/qc/utils.py @@ -116,6 +116,12 @@ def parse_args(): type=str, default="top,bot", ) + parser.add_argument( + "--ctrl_cond", + help="Values in of column in `ReporterScreen.samples[condition_label]` for guide-level editing rate to be calculated", + type=str, + default="bulk", + ) parser.add_argument( "--recalculate-edits", help="Even when ReporterScreen.layers['edit_count'] exists, recalculate the edit counts from ReporterScreen.uns['allele_count'].", diff --git a/bin/bean-qc b/bin/bean-qc index 70e3d22..c3eb72e 100644 --- a/bin/bean-qc +++ b/bin/bean-qc @@ -31,6 +31,7 @@ def main(): condition_label=args.condition_label, comp_cond1=args.lfc_cond1, comp_cond2=args.lfc_cond2, + ctrl_cond=args.ctrl_cond, exp_id=args.out_report_prefix, recalculate_edits=args.recalculate_edits, ), diff --git a/bin/bean-run b/bin/bean-run index d3bd879..f2282ac 100644 --- a/bin/bean-run +++ b/bin/bean-run @@ -199,7 +199,10 @@ def main(args, bdata): target_info_df["n_guides"] = _obtain_n_guides_alleles_per_variant(ndata).cpu() target_info_df["n_coocc"] = _obtain_n_cooccurring_variants(ndata) if args.adjust_confidence_by_negative_control: - adj_negctrl_idx = np.where(target_info_df.ref == target_info_df.alt)[0] + adj_negctrl_idx = np.where( + (target_info_df.ref == target_info_df.alt) + & (target_info_df.coding == "coding") + )[0] print("syn idx: ", adj_negctrl_idx) guide_info_df = ndata.screen.guides diff --git a/notebooks/sample_quality_report.ipynb b/notebooks/sample_quality_report.ipynb index a2e2707..70b7c44 100644 --- a/notebooks/sample_quality_report.ipynb +++ b/notebooks/sample_quality_report.ipynb @@ -54,6 +54,7 @@ "condition_label = \"bin\"\n", "comp_cond1 = \"top\"\n", "comp_cond2 = \"bot\"\n", + "ctrl_cond = \"bulk\"\n", "recalculate_edits = False\n", "tiling = None" ] @@ -262,8 +263,10 @@ "source": [ "if \"edits\" in bdata.layers.keys():\n", " bdata.get_guide_edit_rate(\n", - " editable_base_start = edit_quantification_start_pos, \n", - " editable_base_end=edit_quantification_end_pos)\n", + " editable_base_start=edit_quantification_start_pos,\n", + " editable_base_end=edit_quantification_end_pos,\n", + " unsorted_condition_label=ctrl_cond,\n", + " )\n", " be.qc.plot_guide_edit_rates(bdata)" ] }, @@ -316,7 +319,7 @@ "bdata.samples.loc[bdata.samples.median_corr_X < corr_X_thres, 'mask'] = 0\n", "if \"median_editing_rate\" in bdata.samples.columns.tolist():\n", " bdata.samples.loc[bdata.samples.median_editing_rate < edit_rate_thres, 'mask'] = 0\n", - "bdata_filtered = bdata[:, bdata.samples[\"median_lfc_corr.top_bot\"] > lfc_thres]" + "bdata_filtered = bdata[:, bdata.samples[f\"median_lfc_corr.{comp_cond1}_{comp_cond2}\"] > lfc_thres]" ] }, { From 5bb3797eac8b454a3dfb57101c1ae76e10514704 Mon Sep 17 00:00:00 2001 From: Jayoung Ryu Date: Wed, 22 Nov 2023 15:43:06 -0500 Subject: [PATCH 03/27] fix typo --- bean/qc/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bean/qc/utils.py b/bean/qc/utils.py index 35cd47e..1282e2c 100644 --- a/bean/qc/utils.py +++ b/bean/qc/utils.py @@ -117,7 +117,7 @@ def parse_args(): default="top,bot", ) parser.add_argument( - "--ctrl_cond", + "--ctrl-cond", help="Values in of column in `ReporterScreen.samples[condition_label]` for guide-level editing rate to be calculated", type=str, default="bulk", From 085501a2bfb47c129f77d46a96dee16b2165dd82 Mon Sep 17 00:00:00 2001 From: Jayoung Ryu Date: Wed, 22 Nov 2023 15:55:31 -0500 Subject: [PATCH 04/27] fix guide index, lower correlation threshold --- bean/qc/guide_qc.py | 2 +- bean/qc/utils.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bean/qc/guide_qc.py b/bean/qc/guide_qc.py index dde4113..309a775 100644 --- a/bean/qc/guide_qc.py +++ b/bean/qc/guide_qc.py @@ -30,7 +30,7 @@ def get_outlier_guides_and_mask( ).fillna(1) print(outlier_guides) for _, row in outlier_guides.iterrows(): - mask.loc[row[bdata.guides.index.name], row[replicate_col]] = 0 + mask.loc[row["name"], row[replicate_col]] = 0 return outlier_guides, mask diff --git a/bean/qc/utils.py b/bean/qc/utils.py index 1282e2c..48945d0 100644 --- a/bean/qc/utils.py +++ b/bean/qc/utils.py @@ -84,7 +84,7 @@ def parse_args(): "--count-correlation-thres", help="Correlation threshold to mask out.", type=float, - default=0.8, + default=0.7, ) parser.add_argument( "--edit-rate-thres", From 84a03d0b96ffb277489885a06e92ec7e67a9f27c Mon Sep 17 00:00:00 2001 From: Jayoung Ryu Date: Sat, 25 Nov 2023 14:55:28 -0500 Subject: [PATCH 05/27] allow experimental condition sample covaraintes unrelated to selection --- README.md | 2 +- bean/framework/ReporterScreen.py | 18 +++++- bean/preprocessing/data_class.py | 5 +- bean/qc/guide_qc.py | 26 ++++++--- bean/qc/utils.py | 72 +++++++++++++++++------ bin/bean-qc | 1 + notebooks/sample_quality_report.ipynb | 83 +++++++++++++++++++++------ setup.py | 4 +- 8 files changed, 161 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index a18ad4a..5069fa7 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ File should contain following columns with header. * `R1_filepath`: Path to read 1 `.fastq[.gz]` file * `R2_filepath`: Path to read 1 `.fastq[.gz]` file * `sample_id`: ID of sequencing sample -* `rep [Optional]`: Replicate # of this sample +* `rep [Optional]`: Replicate # of this sample (Should NOT contain `.`) * `bin [Optional]`: Name of the sorting bin * `upper_quantile [Optional]`: FACS sorting upper quantile * `lower_quantile [Optional]`: FACS sorting lower quantile diff --git a/bean/framework/ReporterScreen.py b/bean/framework/ReporterScreen.py index 05eeb3d..5d012c6 100644 --- a/bean/framework/ReporterScreen.py +++ b/bean/framework/ReporterScreen.py @@ -323,8 +323,20 @@ def __getitem__(self, index): new_uns = deepcopy(self.uns) for k, df in adata.uns.items(): if k.startswith("repguide_mask"): - new_uns[k] = df.loc[guides_include, adata.var.rep.unique()] + if "sample_covariates" in adata.uns: + adata.var["_rc"] = adata.var[ + ["rep"] + adata.uns["sample_covariates"] + ].values.tolist() + adata.var["_rc"] = adata.var["_rc"].map( + lambda slist: ".".join(slist) + ) + new_uns[k] = df.loc[guides_include, adata.var._rc.unique()] + adata.var.pop("_rc") + else: + new_uns[k] = df.loc[guides_include, adata.var.rep.unique()] if not isinstance(df, pd.DataFrame): + if k == "sample_covariates": + new_uns[k] = df continue if "guide" in df.columns: if "allele" in df.columns: @@ -892,7 +904,7 @@ def concat(screens: Collection[ReporterScreen], *args, axis=1, **kwargs): if axis == 0: for k in keys: - if k in ["target_base_change", "tiling"]: + if k in ["target_base_change", "tiling", "sample_covariates"]: adata.uns[k] = screens[0].uns[k] continue elif "edit" not in k and "allele" not in k: @@ -902,7 +914,7 @@ def concat(screens: Collection[ReporterScreen], *args, axis=1, **kwargs): if axis == 1: # If combining multiple samples, edit/allele tables should be merged. for k in keys: - if k in ["target_base_change", "tiling"]: + if k in ["target_base_change", "tiling", "sample_covariates"]: adata.uns[k] = screens[0].uns[k] continue elif "edit" not in k and "allele" not in k: diff --git a/bean/preprocessing/data_class.py b/bean/preprocessing/data_class.py index e3c518d..0a16039 100644 --- a/bean/preprocessing/data_class.py +++ b/bean/preprocessing/data_class.py @@ -2,7 +2,7 @@ import abc import logging from dataclasses import dataclass -from typing import Dict, Tuple +from typing import Dict, Tuple, List from xmlrpc.client import Boolean from copy import deepcopy import torch @@ -40,6 +40,7 @@ def __init__( sample_mask_column: str = None, shrink_alpha: bool = False, condition_column: str = "sort", + sample_covariate_column: List[str] = [], control_condition: str = "bulk", accessibility_col: str = None, accessibility_bw_path: str = None, @@ -51,6 +52,7 @@ def __init__( ): """ Args + condition_column: By default, a single condition column, but you can optionally inlcude sample covariate column control_can_be_selected: If True, screen.samples[condition_column] == control_condition can also be included in effect size inference if its condition column is not NA (Currently only suppoted for prolifertion screens). """ # TODO: remove replicate with too small number of (ex. only 1) sorting bin @@ -821,6 +823,7 @@ def __init__( sample_mask_column: str = None, shrink_alpha: bool = False, condition_column: str = "sort", + sample_covariate_column: List[str] = [], control_condition: str = "bulk", lower_quantile_column: str = "lower_quantile", upper_quantile_column: str = "upper_quantile", diff --git a/bean/qc/guide_qc.py b/bean/qc/guide_qc.py index 309a775..1bbf379 100644 --- a/bean/qc/guide_qc.py +++ b/bean/qc/guide_qc.py @@ -22,15 +22,27 @@ def get_outlier_guides_and_mask( abs_RPM_thres: RPM threshold value that will be used to define outlier guides. """ outlier_guides = get_outlier_guides(bdata, condit_col, mad_z_thres, abs_RPM_thres) - outlier_guides[replicate_col] = bdata.samples.loc[ - outlier_guides["sample"], replicate_col - ].values - mask = pd.DataFrame( - index=bdata.guides.index, columns=bdata.samples[replicate_col].unique() - ).fillna(1) + if not isinstance(replicate_col, str): + outlier_guides["_rc"] = bdata.samples.loc[ + outlier_guides["sample"], replicate_col + ].values.tolist() + outlier_guides["_rc"] = outlier_guides["_rc"].map(lambda slist: ".".join(slist)) + else: + outlier_guides[replicate_col] = bdata.samples.loc[ + outlier_guides["sample"], replicate_col + ].values + if isinstance(replicate_col, str): + reps = bdata.samples[replicate_col].unique() + else: + reps = bdata.samples[replicate_col].drop_duplicates().to_records(index=False) + reps = [".".join(slist) for slist in reps] + mask = pd.DataFrame(index=bdata.guides.index, columns=reps).fillna(1) print(outlier_guides) for _, row in outlier_guides.iterrows(): - mask.loc[row["name"], row[replicate_col]] = 0 + mask.loc[ + row["name"], row[replicate_col if isinstance(replicate_col, str) else "_rc"] + ] = 0 + return outlier_guides, mask diff --git a/bean/qc/utils.py b/bean/qc/utils.py index 48945d0..f584783 100644 --- a/bean/qc/utils.py +++ b/bean/qc/utils.py @@ -1,4 +1,5 @@ import distutils +from typing import Union, List import numpy as np import pandas as pd from copy import deepcopy @@ -52,12 +53,23 @@ def parse_args(): type=str, default="rep", ) + parser.add_argument( + "--sample-covariates", + help="Comma-separated list of column names in `bdata.samples` that describes non-selective experimental condition. (drug treatment, etc.)", + type=str, + default=None, + ) parser.add_argument( "--condition-label", help="Label of column in `bdata.samples` that describes experimental condition. (sorting bin, time, etc.)", type=str, default="bin", ) + parser.add_argument( + "--no-editing", + help="Ignore QC about editing. Can be used for QC of other editing modalities.", + action="store_true", + ) parser.add_argument( "--target-pos-col", help="Target position column in `bdata.guides` specifying target edit position in reporter", @@ -143,21 +155,30 @@ def check_args(args): ) args.lfc_cond1 = lfc_conds[0] args.lfc_cond2 = lfc_conds[1] + if args.sample_covariates is not None: + if "," in args.sample_covariates: + args.sample_covariates = args.sample_covariates.split(",") + args.replicate_label = [args.replicate_label] + args.sample_covariates + else: + args.replicate_label = [args.replicate_label, args.sample_covariates] + if args.no_editing: + args.base_edit_data = False + else: + args.base_edit_data = True return args -def _add_dummy_sample(bdata, rep, cond, condition_label: str, replicate_label: str): +def _add_dummy_sample( + bdata, rep, cond, condition_label: str, replicate_label: Union[str, List[str]] +): sample_id = f"{rep}_{cond}" cond_df = deepcopy(bdata.samples) - cond_df[replicate_label] = np.nan - cond_df = cond_df.drop_duplicates() + # cond_df = cond_df.drop_duplicates() cond_row = cond_df.loc[cond_df[condition_label] == cond, :] - if not len(cond_row) == 1: - raise ValueError( - f"Non-unique condition specification in ReporterScreen.samples: {cond_row}" - ) + if len(cond_row) != 1: + cond_row = cond_row.iloc[[0], :] cond_row.index = [sample_id] - cond_row.loc[:, replicate_label] = rep + cond_row[replicate_label] = rep dummy_sample_bdata = ReporterScreen( X=np.zeros((bdata.n_obs, 1)), X_bcmatch=np.zeros((bdata.n_obs, 1)), @@ -175,27 +196,40 @@ def _add_dummy_sample(bdata, rep, cond, condition_label: str, replicate_label: s return bdata -def fill_in_missing_samples(bdata, condition_label: str, replicate_label: str): +def fill_in_missing_samples( + bdata, condition_label: str, replicate_label: Union[str, List[str]] +): """If not all condition exists for every replicate in bdata, fill in fake sample""" added_dummy = False - for rep in bdata.samples[replicate_label].unique(): + if isinstance(replicate_label, str): + rep_list = bdata.samples[replicate_label].unique() + else: + rep_list = ( + bdata.samples[replicate_label].drop_duplicates().to_records(index=False) + ) + # print(rep_list) + for rep in rep_list: for cond in bdata.samples[condition_label].unique(): + if isinstance(replicate_label, str): + rep_samples = bdata.samples[replicate_label] == rep + else: + rep = list(rep) + rep_samples = (bdata.samples[replicate_label] == rep).all(axis=1) if ( - len( - np.where( - (bdata.samples[replicate_label] == rep) - & (bdata.samples[condition_label] == cond) - )[0] - ) + len(np.where(rep_samples & (bdata.samples[condition_label] == cond))[0]) != 1 ): + print(f"Adding dummy samples for {rep}, {cond}") bdata = _add_dummy_sample( bdata, rep, cond, condition_label, replicate_label ) if not added_dummy: added_dummy = True if added_dummy: - bdata = bdata[ - :, bdata.samples.sort_values([replicate_label, condition_label]).index - ] + if isinstance(replicate_label, str): + sort_labels = [replicate_label, condition_label] + else: + sort_labels = replicate_label + [condition_label] + bdata = bdata[:, bdata.samples.sort_values(sort_labels).index] + return bdata diff --git a/bin/bean-qc b/bin/bean-qc index c3eb72e..c83d6f2 100644 --- a/bin/bean-qc +++ b/bin/bean-qc @@ -34,6 +34,7 @@ def main(): ctrl_cond=args.ctrl_cond, exp_id=args.out_report_prefix, recalculate_edits=args.recalculate_edits, + base_edit_data=args.base_edit_data, ), kernel_name="bean_python3", ) diff --git a/notebooks/sample_quality_report.ipynb b/notebooks/sample_quality_report.ipynb index 70b7c44..205d3a3 100644 --- a/notebooks/sample_quality_report.ipynb +++ b/notebooks/sample_quality_report.ipynb @@ -56,7 +56,8 @@ "comp_cond2 = \"bot\"\n", "ctrl_cond = \"bulk\"\n", "recalculate_edits = False\n", - "tiling = None" + "tiling = None\n", + "base_edit_data = True" ] }, { @@ -75,7 +76,9 @@ "outputs": [], "source": [ "if tiling is not None:\n", - " bdata.uns['tiling'] = tiling" + " bdata.uns['tiling'] = tiling\n", + "if not isinstance(replicate_label, str):\n", + " bdata.uns['sample_covariates'] = replicate_label[1:]" ] }, { @@ -208,12 +211,7 @@ "outputs": [], "source": [ "selected_guides = bdata.guides[posctrl_col] == posctrl_val if posctrl_col else ~bdata.guides.index.isnull()\n", - "ax=pt.qc.plot_lfc_correlation(bdata, selected_guides, method=\"Spearman\", cond1=comp_cond1, cond2=comp_cond2, rep_col=replicate_label, compare_col=condition_label, figsize=(10,10))\n", - "\n", - "ax.set_title(\"top/bot LFC correlation, Spearman\")\n", - "plt.yticks(rotation=0) \n", - "plt.xticks(rotation=90) \n", - "plt.show()" + "print(f\"Calculating LFC correlation of {sum(selected_guides)} {'positive control' if posctrl_col else 'all'} guides.\")" ] }, { @@ -221,7 +219,23 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [] + "source": [ + "ax = pt.qc.plot_lfc_correlation(\n", + " bdata,\n", + " selected_guides,\n", + " method=\"Spearman\",\n", + " cond1=comp_cond1,\n", + " cond2=comp_cond2,\n", + " rep_col=replicate_label,\n", + " compare_col=condition_label,\n", + " figsize=(10, 10),\n", + ")\n", + "\n", + "ax.set_title(\"top/bot LFC correlation, Spearman\")\n", + "plt.yticks(rotation=0)\n", + "plt.xticks(rotation=90)\n", + "plt.show()" + ] }, { "cell_type": "code", @@ -243,7 +257,12 @@ "metadata": {}, "outputs": [], "source": [ - "if recalculate_edits or \"edits\" not in bdata.layers.keys() or bdata.layers['edits'].max() == 0:\n", + "if \"target_base_change\" not in bdata.uns or not base_edit_data:\n", + " bdata.uns[\"target_base_change\"] = \"\"\n", + " base_edit_data = False\n", + " print(\"Not a base editing data or target base change not provided. Passing editing-related QC\")\n", + " edit_rate_threshold = -0.1\n", + "elif recalculate_edits or \"edits\" not in bdata.layers.keys() or bdata.layers['edits'].max() == 0:\n", " if 'allele_counts' in bdata.uns.keys():\n", " bdata.uns['allele_counts'] = bdata.uns['allele_counts'].loc[bdata.uns['allele_counts'].allele.map(str) != \"\"]\n", " bdata.get_edit_from_allele()\n", @@ -261,12 +280,22 @@ "metadata": {}, "outputs": [], "source": [ - "if \"edits\" in bdata.layers.keys():\n", + "if \"target_base_change\" not in bdata.uns or not base_edit_data:\n", + " print(\n", + " \"Not a base editing data or target base change not provided. Passing editing-related QC\"\n", + " )\n", + "elif \"edits\" in bdata.layers.keys():\n", + "\n", " bdata.get_guide_edit_rate(\n", + "\n", " editable_base_start=edit_quantification_start_pos,\n", + "\n", " editable_base_end=edit_quantification_end_pos,\n", + "\n", " unsorted_condition_label=ctrl_cond,\n", + "\n", " )\n", + "\n", " be.qc.plot_guide_edit_rates(bdata)" ] }, @@ -276,11 +305,19 @@ "metadata": {}, "outputs": [], "source": [ - "if \"edits\" in bdata.layers.keys():\n", + "if \"target_base_change\" not in bdata.uns or not base_edit_data:\n", + " print(\n", + " \"Not a base editing data or target base change not provided. Passing editing-related QC\"\n", + " )\n", + "elif \"edits\" in bdata.layers.keys():\n", + "\n", " bdata.get_edit_rate(\n", - " editable_base_start = edit_quantification_start_pos, \n", - " editable_base_end=edit_quantification_end_pos\n", + " editable_base_start=edit_quantification_start_pos,\n", + "\n", + " editable_base_end=edit_quantification_end_pos,\n", + "\n", " )\n", + "\n", " be.qc.plot_sample_edit_rates(bdata)" ] }, @@ -329,12 +366,24 @@ "outputs": [], "source": [ "# leave replicate with more than 1 sorting bin data\n", - "rep_n_samples = bdata_filtered.samples.groupby(replicate_label)['mask'].sum()\n", + "rep_n_samples = bdata_filtered.samples.groupby(replicate_label)[\"mask\"].sum()\n", "print(rep_n_samples)\n", "rep_has_too_small_sample = rep_n_samples.loc[rep_n_samples < 2].index.tolist()\n", "rep_has_too_small_sample\n", - "print(f\"Excluding reps {rep_has_too_small_sample} that has less than 2 samples per replicate.\")\n", - "bdata_filtered = bdata_filtered[:, ~bdata_filtered.samples[replicate_label].isin(rep_has_too_small_sample)]" + "print(\n", + " f\"Excluding reps {rep_has_too_small_sample} that has less than 2 samples per replicate.\"\n", + ")\n", + "if isinstance(replicate_label, str):\n", + " samples_include = ~bdata_filtered.samples[replicate_label].isin(\n", + " rep_has_too_small_sample\n", + " )\n", + "else:\n", + " bdata_filtered.samples[\"_rc\"] = bdata_filtered.samples[\n", + " replicate_label\n", + " ].values.tolist()\n", + " samples_include = ~bdata_filtered.samples[\"_rc\"].isin(rep_has_too_small_sample)\n", + "bdata_filtered = bdata_filtered[:, samples_include]\n", + "bdata_filtered.samples.pop(\"_rc\")" ] }, { diff --git a/setup.py b/setup.py index 4b2157f..f41178c 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ setup( name="crispr-bean", - version="0.2.9", + version="0.3.0", python_requires=">=3.8.0", author="Jayoung Ryu", author_email="jayoung_ryu@g.harvard.edu", @@ -36,7 +36,7 @@ "numpy", "pandas", "scipy", - "perturb-tools>=0.2.8", + "perturb-tools>=0.3.0", "matplotlib", "seaborn>=0.13.0", "tqdm", From 5656d92dfa18571846cc782ef945be39cf37090a Mon Sep 17 00:00:00 2001 From: Jayoung Ryu Date: Sat, 25 Nov 2023 14:58:33 -0500 Subject: [PATCH 06/27] allow condition & non-BE data --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 5069fa7..2504b2e 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,8 @@ Above command produces * `--tiling` (default: `None`): If set as `True` or `False`, it sets the screen object to be tiling (`True`) or variant (`False`)-targeting screen when calculating editing rate. * `--replicate-label` (default: `"rep"`): Label of column in `bdata.samples` that describes replicate ID. * `--condition-label` (default: `"bin"`)": Label of column in `bdata.samples` that describes experimental condition. (sorting bin, time, etc.). +* `--sample-covariates` (default: `None`): Comma-separated list of column names in `bdata.samples` that describes non-selective experimental condition (drug treatment, etc.). The values in the `bdata.samples` should NOT contain `.`. +* `--no-editing` (default: `False`): Ignore QC about editing. Can be used for QC of other editing modalities. * `--target-pos-col` (default: `"target_pos"`): Target position column in `bdata.guides` specifying target edit position in reporter. * `--rel-pos-is-reporter` (default: `False`): Specifies whether `edit_start_pos` and `edit_end_pos` are relative to reporter position. If `False`, those are relative to spacer position. * `--edit-start-pos` (default: `2`): Edit start position to quantify editing rate on, 0-based inclusive. From ee96e71b0a34cb1236479a0406e82f0aad86dd71 Mon Sep 17 00:00:00 2001 From: Jayoung Ryu Date: Sat, 25 Nov 2023 17:51:15 -0500 Subject: [PATCH 07/27] Initial run with condition --- bean/framework/ReporterScreen.py | 6 ++- bean/model/model.py | 73 +++++++++++++++++++-------- bean/model/utils.py | 28 ++++++---- bean/preprocessing/data_class.py | 43 ++++++++++++++-- bean/preprocessing/utils.py | 8 +-- bin/bean-run | 20 ++++++-- notebooks/sample_quality_report.ipynb | 17 ++++--- 7 files changed, 144 insertions(+), 51 deletions(-) diff --git a/bean/framework/ReporterScreen.py b/bean/framework/ReporterScreen.py index 5d012c6..37631d5 100644 --- a/bean/framework/ReporterScreen.py +++ b/bean/framework/ReporterScreen.py @@ -99,6 +99,8 @@ def __init__( self.layers["X_bcmatch"] = X_bcmatch for k, df in self.uns.items(): if not isinstance(df, pd.DataFrame): + if k == "sample_covariates" and not isinstance(df, list): + self.uns[k] = df.tolist() continue if "guide" in df.columns and len(df) > 0: if ( @@ -325,13 +327,13 @@ def __getitem__(self, index): if k.startswith("repguide_mask"): if "sample_covariates" in adata.uns: adata.var["_rc"] = adata.var[ - ["rep"] + adata.uns["sample_covariates"] + ["rep"] + list(adata.uns["sample_covariates"]) ].values.tolist() adata.var["_rc"] = adata.var["_rc"].map( lambda slist: ".".join(slist) ) new_uns[k] = df.loc[guides_include, adata.var._rc.unique()] - adata.var.pop("_rc") + #adata.var.pop("_rc") else: new_uns[k] = df.loc[guides_include, adata.var.rep.unique()] if not isinstance(df, pd.DataFrame): diff --git a/bean/model/model.py b/bean/model/model.py index c6be6e9..91514f1 100644 --- a/bean/model/model.py +++ b/bean/model/model.py @@ -45,26 +45,51 @@ def NormalModel( sd = sd_alleles sd = torch.repeat_interleave(sd, data.target_lengths, dim=0) assert sd.shape == (data.n_guides, 1) - + if data.sample_covariates is not None: + with pyro.plate("cov_place", data.n_sample_covariates): + mu_cov = pyro.sample("mu_cov", dist.Normal(0, 1)) + assert mu_cov.shape == (data.n_sample_covariates,), mu_cov.shape with replicate_plate: with bin_plate as b: uq = data.upper_bounds[b] lq = data.lower_bounds[b] assert uq.shape == lq.shape == (data.n_condits,) - # with guide_plate, poutine.mask(mask=(data.allele_counts.sum(axis=-1) == 0)): with guide_plate: + mu = mu.unsqueeze(0).unsqueeze(0).expand( + (data.n_reps, data.n_condits, -1, -1) + ) + (data.rep_by_cov * mu_cov)[:, 0].unsqueeze(-1).unsqueeze( + -1 + ).unsqueeze( + -1 + ).expand( + (-1, data.n_condits, data.n_guides, 1) + ) + sd = torch.sqrt( + ( + sd.unsqueeze(0) + .unsqueeze(0) + .expand((data.n_reps, data.n_condits, -1, -1)) + ) + ) alleles_p_bin = get_std_normal_prob( - uq.unsqueeze(-1).unsqueeze(-1).expand((-1, data.n_guides, 1)), - lq.unsqueeze(-1).unsqueeze(-1).expand((-1, data.n_guides, 1)), - mu.unsqueeze(0).expand((data.n_condits, -1, -1)), - sd.unsqueeze(0).expand((data.n_condits, -1, -1)), + uq.unsqueeze(0) + .unsqueeze(-1) + .unsqueeze(-1) + .expand((data.n_reps, -1, data.n_guides, 1)), + lq.unsqueeze(0) + .unsqueeze(-1) + .unsqueeze(-1) + .expand((data.n_reps, -1, data.n_guides, 1)), + mu, + sd, ) - assert alleles_p_bin.shape == (data.n_condits, data.n_guides, 1) - - expected_allele_p = alleles_p_bin.unsqueeze(0).expand( - data.n_reps, -1, -1, -1 - ) - expected_guide_p = expected_allele_p.sum(axis=-1) + assert alleles_p_bin.shape == ( + data.n_reps, + data.n_condits, + data.n_guides, + 1, + ) + expected_guide_p = alleles_p_bin.sum(axis=-1) assert expected_guide_p.shape == ( data.n_reps, data.n_condits, @@ -158,14 +183,10 @@ def ControlNormalModel(data, mask_thres=10, use_bcmatch=True): with pyro.plate("guide_plate3", data.n_guides, dim=-1): a = get_alpha(expected_guide_p, data.size_factor, data.sample_mask, data.a0) - assert ( - data.X.shape - == data.X_bcmatch.shape - == ( - data.n_reps, - data.n_condits, - data.n_guides, - ) + assert data.X.shape == ( + data.n_reps, + data.n_condits, + data.n_guides, ) with poutine.mask( mask=torch.logical_and( @@ -490,6 +511,18 @@ def NormalGuide(data): constraint=constraints.positive, ) pyro.sample("sd_alleles", dist.LogNormal(sd_loc, sd_scale)) + if data.sample_covariates is not None: + with pyro.plate("cov_place", data.n_sample_covariates): + mu_cov_loc = pyro.param( + "mu_cov_loc", torch.zeros((data.n_sample_covariates,)) + ) + mu_cov_scale = pyro.param( + "mu_cov_scale", + torch.ones((data.n_sample_covariates,)), + constraint=constraints.positive, + ) + mu_cov = pyro.sample("mu_cov", dist.Normal(mu_cov_loc, mu_cov_scale)) + assert mu_cov.shape == (data.n_sample_covariates,), mu_cov.shape def MixtureNormalGuide( diff --git a/bean/model/utils.py b/bean/model/utils.py index 7767232..7dfba0a 100644 --- a/bean/model/utils.py +++ b/bean/model/utils.py @@ -8,17 +8,23 @@ def get_alpha( expected_guide_p, size_factor, sample_mask, a0, epsilon=1e-5, normalize_by_a0=True ): - p = ( - expected_guide_p.permute(0, 2, 1) * size_factor[:, None, :] - ) # (n_reps, n_guides, n_bins) - if normalize_by_a0: - a = ( - (p + epsilon / p.shape[-1]) - / (p.sum(axis=-1)[:, :, None] + epsilon) - * a0[None, :, None] - ) - a = (a * sample_mask[:, None, :]).clamp(min=epsilon) - return a + try: + p = ( + expected_guide_p.permute(0, 2, 1) * size_factor[:, None, :] + ) # (n_reps, n_guides, n_bins) + + if normalize_by_a0: + a = ( + (p + epsilon / p.shape[-1]) + / (p.sum(axis=-1)[:, :, None] + epsilon) + * a0[None, :, None] + ) + a = (a * sample_mask[:, None, :]).clamp(min=epsilon) + return a + except: + print(size_factor.shape) + print(expected_guide_p.shape) + print(a0.shape) a = (p * sample_mask[:, None, :]).clamp(min=epsilon) return a diff --git a/bean/preprocessing/data_class.py b/bean/preprocessing/data_class.py index 0a16039..9f01947 100644 --- a/bean/preprocessing/data_class.py +++ b/bean/preprocessing/data_class.py @@ -60,17 +60,34 @@ def __init__( self.device = device screen.samples["size_factor"] = self.get_size_factor(screen.X) if not ( - "rep" in screen.samples.columns + replicate_column in screen.samples.columns and condition_column in screen.samples.columns ): - screen.samples["rep"], screen.samples[condition_column] = zip( + screen.samples[replicate_column], screen.samples[condition_column] = zip( *screen.samples.index.map(lambda s: s.rsplit("_", 1)) ) if condition_column not in screen.samples.columns: screen.samples[condition_column] = screen.samples["index"].map( lambda s: s.split("_")[-1] ) - + if "sample_covariates" in screen.uns: + self.sample_covariates = screen.uns["sample_covariates"] + self.n_sample_covariates = len(self.sample_covariates) + screen.samples["_rc"] = screen.samples[ + [replicate_column] + self.sample_covariates + ].values.tolist() + screen.samples["_rc"] = screen.samples["_rc"].map( + lambda slist: ".".join(slist) + ) + self.rep_by_cov = torch.as_tensor( + ( + screen.samples[["_rc"] + self.sample_covariates] + .drop_duplicates() + .set_index("_rc") + .values.astype(int) + ) + ) + replicate_column = "_rc" self.screen = screen if not control_can_be_selected: self.screen_selected = screen[ @@ -146,7 +163,7 @@ def _post_init( ).all() assert ( self.screen_selected.uns[self.repguide_mask].columns - == self.screen_selected.samples.rep.unique() + == self.screen_selected.samples[self.replicate_column].unique() ).all() self.repguide_mask = ( torch.as_tensor(self.screen_selected.uns[self.repguide_mask].values.T) @@ -182,6 +199,7 @@ def __getitem__(self, guide_idx): ndata.X_masked = ndata.X_masked[:, :, guide_idx] ndata.X_control = ndata.X_control[:, :, guide_idx] ndata.repguide_mask = ndata.repguide_mask[:, guide_idx] + ndata.a0 = ndata.a0[guide_idx] return ndata def transform_data(self, X, n_bins=None): @@ -905,9 +923,20 @@ def _pre_init( self.screen.samples.loc[ self.screen_selected.samples.index, f"{self.condition_column}_id" ] = self.screen_selected.samples[f"{self.condition_column}_id"] + print(self.screen.samples.columns) self.screen = _assign_rep_ids_and_sort( self.screen, self.replicate_column, self.condition_column ) + print(self.screen.samples.columns) + if self.sample_covariates is not None: + self.rep_by_cov = torch.as_tensor( + ( + self.screen.samples[["_rc"] + self.sample_covariates] + .drop_duplicates() + .set_index("_rc") + .values.astype(int) + ) + ) self.screen_selected = _assign_rep_ids_and_sort( self.screen_selected, self.replicate_column, self.condition_column ) @@ -986,8 +1015,12 @@ def _post_init( self.screen = _assign_rep_ids_and_sort( self.screen, self.replicate_column, self.time_column ) + if self.sample_covariates is not None: + self.rep_by_cov = self.screen.samples.groupby(self.replicate_column)[ + self.sample_covariates + ].values self.screen_selected = _assign_rep_ids_and_sort( - self.screen_selected, self.replicate_column, self.time_column + self.screen_selected, self.replicate_column, self.condition_column ) self.screen_control = _assign_rep_ids_and_sort( self.screen_control, diff --git a/bean/preprocessing/utils.py b/bean/preprocessing/utils.py index 8596782..44c6431 100644 --- a/bean/preprocessing/utils.py +++ b/bean/preprocessing/utils.py @@ -219,10 +219,10 @@ def _assign_rep_ids_and_sort( sort_key = f"{rep_col}_id" else: sort_key = [f"{rep_col}_id", f"{condition_column}_id"] - screen = screen[ - :, - screen.samples.sort_values(sort_key).index, - ] + screen = screen[ + :, + screen.samples.sort_values(sort_key).index, + ] return screen diff --git a/bin/bean-run b/bin/bean-run index 669da0b..04d364e 100644 --- a/bin/bean-run +++ b/bin/bean-run @@ -2,6 +2,8 @@ import os import sys import logging +import warnings +from functools import partial from copy import deepcopy import numpy as np import pandas as pd @@ -43,6 +45,11 @@ warn = logging.warning debug = logging.debug info = logging.info pyro.set_rng_seed(101) +warnings.filterwarnings( + "ignore", + category=FutureWarning, + message=r".*is_categorical_dtype is deprecated and will be removed in a future version.*", +) def main(args, bdata): @@ -127,8 +134,15 @@ def main(args, bdata): run_inference(model, guide, ndata, num_steps=args.n_iter) ) if args.fit_negctrl: - negctrl_model = m.ControlNormalModel - negctrl_guide = m.ControlNormalGuide + negctrl_model = partial( + m.ControlNormalModel, + use_bcmatch=(not args.ignore_bcmatch and "X_bcmatch" in bdata.layers), + ) + print((not args.ignore_bcmatch and "X_bcmatch" in bdata.layers)) + negctrl_guide = partial( + m.ControlNormalGuide, + use_bcmatch=(not args.ignore_bcmatch and "X_bcmatch" in bdata.layers), + ) negctrl_idx = np.where( guide_info_df[args.negctrl_col].map(lambda s: s.lower()) == args.negctrl_col_value.lower() @@ -137,7 +151,7 @@ def main(args, bdata): print(negctrl_idx.shape) ndata_negctrl = ndata[negctrl_idx] param_history_dict["negctrl"] = run_inference( - negctrl_model, negctrl_guide, ndata_negctrl + negctrl_model, negctrl_guide, ndata_negctrl, num_steps=args.n_iter ) outfile_path = ( diff --git a/notebooks/sample_quality_report.ipynb b/notebooks/sample_quality_report.ipynb index 205d3a3..bf632b3 100644 --- a/notebooks/sample_quality_report.ipynb +++ b/notebooks/sample_quality_report.ipynb @@ -76,9 +76,10 @@ "outputs": [], "source": [ "if tiling is not None:\n", - " bdata.uns['tiling'] = tiling\n", + " bdata.uns[\"tiling\"] = tiling\n", "if not isinstance(replicate_label, str):\n", - " bdata.uns['sample_covariates'] = replicate_label[1:]" + " bdata.uns[\"sample_covariates\"] = replicate_label[1:]\n", + "bdata.samples[replicate_label] = bdata.samples[replicate_label].astype(str)" ] }, { @@ -352,11 +353,15 @@ "metadata": {}, "outputs": [], "source": [ - "bdata.samples['mask'] = 1\n", - "bdata.samples.loc[bdata.samples.median_corr_X < corr_X_thres, 'mask'] = 0\n", + "bdata.samples[\"mask\"] = 1\n", + "bdata.samples.loc[\n", + " bdata.samples.median_corr_X.isnull() | (bdata.samples.median_corr_X < corr_X_thres), \"mask\"\n", + "] = 0\n", "if \"median_editing_rate\" in bdata.samples.columns.tolist():\n", - " bdata.samples.loc[bdata.samples.median_editing_rate < edit_rate_thres, 'mask'] = 0\n", - "bdata_filtered = bdata[:, bdata.samples[f\"median_lfc_corr.{comp_cond1}_{comp_cond2}\"] > lfc_thres]" + " bdata.samples.loc[bdata.samples.median_editing_rate < edit_rate_thres, \"mask\"] = 0\n", + "bdata_filtered = bdata[\n", + " :, bdata.samples[f\"median_lfc_corr.{comp_cond1}_{comp_cond2}\"] > lfc_thres\n", + "]" ] }, { From 6b1682a85aded0a66585c6c5077bae26c5f7ca79 Mon Sep 17 00:00:00 2001 From: Jayoung Ryu Date: Sat, 25 Nov 2023 18:17:23 -0500 Subject: [PATCH 08/27] debug condition runs --- bean/model/readwrite.py | 48 +++++++++++++++++++++++++++++++- bean/preprocessing/data_class.py | 2 -- bin/bean-run | 8 ++++-- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/bean/model/readwrite.py b/bean/model/readwrite.py index b41de39..c1c68cd 100644 --- a/bean/model/readwrite.py +++ b/bean/model/readwrite.py @@ -1,4 +1,4 @@ -from typing import Union, Sequence, Optional +from typing import Union, Sequence, Optional, List import numpy as np import pandas as pd from statistics import NormalDist @@ -58,6 +58,7 @@ def write_result_table( guide_index: Optional[Sequence[str]] = None, guide_acc: Optional[Sequence] = None, sd_is_fitted: bool = True, + sample_covariates: List[str] = None, return_result: bool = False, ) -> Union[pd.DataFrame, None]: """Combine target information and scores to write result table to a csv file or return it.""" @@ -82,6 +83,24 @@ def write_result_table( } if sd_is_fitted: param_dict["sd"] = sd + if sample_covariates is not None: + assert ( + "mu_cov_loc" in param_hist_dict["params"] + and "mu_cov_scale" in param_hist_dict["params"] + ), param_hist_dict["params"].keys() + for i, sample_cov in enumerate(sample_covariates): + param_dict[f"mu_{sample_cov}"] = ( + mu + param_hist_dict["params"]["mu_cov_loc"].detach().cpu().numpy()[i] + ) + param_dict[f"mu_sd_{sample_cov}"] = np.sqrt( + mu_sd**2 + + param_hist_dict["params"]["mu_cov_scale"].detach().cpu().numpy()[i] + ** 2 + ) + param_dict[f"mu_z_{sample_cov}"] = ( + param_dict[f"mu_{sample_cov}"] / param_dict[f"mu_sd_{sample_cov}"] + ) + fit_df = pd.DataFrame(param_dict) fit_df["novl"] = get_novl(fit_df, "mu", "mu_sd") if "negctrl" in param_hist_dict.keys(): @@ -102,6 +121,17 @@ def write_result_table( if sd_is_fitted: fit_df["sd_scaled"] = sd / sd0 fit_df["novl_scaled"] = get_novl(fit_df, "mu_scaled", "mu_sd_scaled") + if sample_covariates is not None: + for i, sample_cov in enumerate(sample_covariates): + fit_df[f"mu_{sample_cov}_scaled"] = ( + fit_df[f"mu_{sample_cov}"] - mu0 + ) / sd0 + fit_df[f"mu_sd_{sample_cov}_scaled"] = ( + fit_df[f"mu_sd_{sample_cov}"] / sd0 + ) + fit_df[f"mu_z_{sample_cov}_scaled"] = ( + fit_df[f"mu_{sample_cov}_scaled"] / fit_df["mu_sd_scaled"] + ) fit_df = pd.concat( [target_info_df.reset_index(), fit_df.reset_index(drop=True)], axis=1 @@ -132,6 +162,22 @@ def write_result_table( else "mu_sd", ) fit_df = add_credible_interval(fit_df, "mu_adj", "mu_sd_adj") + if sample_covariates is not None: + for i, sample_cov in enumerate(sample_covariates): + fit_df = adjust_normal_params_by_control( + fit_df, + std, + suffix=f"_{sample_cov}_adj", + mu_adjusted_col=f"mu_{sample_cov}_scaled" + if "negctrl" in param_hist_dict.keys() + else f"mu_{sample_cov}", + mu_sd_adjusted_col=f"mu_sd_{sample_cov}_scaled" + if "negctrl" in param_hist_dict.keys() + else f"mu_sd_{sample_cov}", + ) + fit_df = add_credible_interval( + fit_df, f"mu_{sample_cov}_adj", f"mu_sd_{sample_cov}_adj" + ) if write_fitted_eff or guide_acc is not None: if "alpha_pi" not in param_hist_dict["params"].keys(): diff --git a/bean/preprocessing/data_class.py b/bean/preprocessing/data_class.py index 9f01947..505dab7 100644 --- a/bean/preprocessing/data_class.py +++ b/bean/preprocessing/data_class.py @@ -923,11 +923,9 @@ def _pre_init( self.screen.samples.loc[ self.screen_selected.samples.index, f"{self.condition_column}_id" ] = self.screen_selected.samples[f"{self.condition_column}_id"] - print(self.screen.samples.columns) self.screen = _assign_rep_ids_and_sort( self.screen, self.replicate_column, self.condition_column ) - print(self.screen.samples.columns) if self.sample_covariates is not None: self.rep_by_cov = torch.as_tensor( ( diff --git a/bin/bean-run b/bin/bean-run index 04d364e..6bff913 100644 --- a/bin/bean-run +++ b/bin/bean-run @@ -138,7 +138,7 @@ def main(args, bdata): m.ControlNormalModel, use_bcmatch=(not args.ignore_bcmatch and "X_bcmatch" in bdata.layers), ) - print((not args.ignore_bcmatch and "X_bcmatch" in bdata.layers)) + negctrl_guide = partial( m.ControlNormalGuide, use_bcmatch=(not args.ignore_bcmatch and "X_bcmatch" in bdata.layers), @@ -147,8 +147,9 @@ def main(args, bdata): guide_info_df[args.negctrl_col].map(lambda s: s.lower()) == args.negctrl_col_value.lower() )[0] - print(len(negctrl_idx)) - print(negctrl_idx.shape) + info( + f"Using {len(negctrl_idx)} negative control elements to adjust phenotypic effect sizes..." + ) ndata_negctrl = ndata[negctrl_idx] param_history_dict["negctrl"] = run_inference( negctrl_model, negctrl_guide, ndata_negctrl, num_steps=args.n_iter @@ -180,6 +181,7 @@ def main(args, bdata): adjust_confidence_by_negative_control=args.adjust_confidence_by_negative_control, adjust_confidence_negatives=adj_negctrl_idx, sd_is_fitted=(args.selection == "sorting"), + sample_covariates=ndata.sample_covariates, ) info("Done!") From e8632f7d8b12c30317ee0db700d670aa3c6ed0d5 Mon Sep 17 00:00:00 2001 From: Jayoung Ryu Date: Sat, 25 Nov 2023 18:19:27 -0500 Subject: [PATCH 09/27] ignore unsolvable warnings --- bean/preprocessing/data_class.py | 2 +- bin/bean-run | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/bean/preprocessing/data_class.py b/bean/preprocessing/data_class.py index ea4953e..ca8ae90 100644 --- a/bean/preprocessing/data_class.py +++ b/bean/preprocessing/data_class.py @@ -440,7 +440,7 @@ def __getitem__(self, guide_idx): def get_target_lengths(self, screen, target_col="target"): target_len_list = [] screen.guides[target_col] = screen.guides[target_col].astype("category") - cur_item = screen.guides[target_col].cat.codes[0] + cur_item = screen.guides[target_col].cat.codes.iloc[0] cur_len = 0 n_targets = 0 for i in screen.guides[target_col].cat.codes: diff --git a/bin/bean-run b/bin/bean-run index f2282ac..5e44330 100644 --- a/bin/bean-run +++ b/bin/bean-run @@ -2,6 +2,7 @@ import os import sys import logging +import warnings from copy import deepcopy from functools import partial import numpy as np @@ -58,6 +59,17 @@ DATACLASS_DICT = { "MultiMixtureNormal+Acc": TilingSortingReporterScreenData, } +warnings.filterwarnings( + "ignore", + category=FutureWarning, + message=r".*is_categorical_dtype is deprecated and will be removed in a future version.*", +) +warnings.filterwarnings( + "ignore", + category=FutureWarning, + message=r".*FutureWarning: The default of observed=False is deprecated and will be changed to True in a future version of pandas.*", +) + def identify_model_guide(args): if args.mode == "tiling": From 32cd6b70056b0ebe78b03b8fb3e60a8144b0f8c0 Mon Sep 17 00:00:00 2001 From: Jayoung Ryu Date: Sat, 25 Nov 2023 18:48:12 -0500 Subject: [PATCH 10/27] add per-variant stat --- notebooks/sample_quality_report.ipynb | 78 +++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 3 deletions(-) diff --git a/notebooks/sample_quality_report.ipynb b/notebooks/sample_quality_report.ipynb index 70b7c44..4d056ea 100644 --- a/notebooks/sample_quality_report.ipynb +++ b/notebooks/sample_quality_report.ipynb @@ -20,11 +20,13 @@ "metadata": {}, "outputs": [], "source": [ + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", "import perturb_tools as pt\n", "import bean as be\n", "from bean.qc.utils import fill_in_missing_samples\n", - "import matplotlib.pyplot as plt\n", - "plt.style.use('default')" + "\n", + "plt.style.use(\"default\")" ] }, { @@ -282,6 +284,46 @@ " editable_base_end=edit_quantification_end_pos\n", " )\n", " be.qc.plot_sample_edit_rates(bdata)" +<<<<<<< Updated upstream +======= + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 5. Variant coverage" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if not tiling:\n", + " n_guides = bdata.guides.groupby(\"target\").size()\n", + " int_bins = np.arange(min(0.5, n_guides.min() - 0.5), n_guides.max() + 0.5, 1)\n", + " plt.hist(n_guides, bins=int_bins)\n", + " plt.xticks(np.arange(n_guides.min() - 1, n_guides.max() + 1, 1))\n", + " plt.title(\"# Guides per target\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if \"target_base_change\" not in bdata.uns or not base_edit_data:\n", + " print(\n", + " \"Not a base editing data or target base change not provided. Passing editing-related QC\"\n", + " )\n", + "elif tiling:\n", + " total_edits = bdata.guides.groupby(\"target\")[\"edit_rate\"].sum()\n", + " plt.hist(n_guides)\n", + " plt.title(\"Total edit rates per target\")" +>>>>>>> Stashed changes ] }, { @@ -348,12 +390,42 @@ "bdata_filtered.samples.style.background_gradient(cmap=\"coolwarm_r\")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 5. Variant coverage" + ] + }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], - "source": [] + "source": [ + "if not tiling:\n", + " n_guides = bdata.guides.groupby(\"target\").size()\n", + " int_bins = np.arange(min(0.5, n_guides.min() - 0.5), n_guides.max() + 0.5, 1)\n", + " plt.hist(n_guides, bins=int_bins)\n", + " plt.xticks(np.arange(n_guides.min() - 1, n_guides.max() + 1, 1))\n", + " plt.title(\"# Guides per target\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if \"target_base_change\" not in bdata.uns or not base_edit_data:\n", + " print(\n", + " \"Not a base editing data or target base change not provided. Passing editing-related QC\"\n", + " )\n", + "elif not tiling:\n", + " total_edits = bdata.guides.groupby(\"target\")[\"edit_rate\"].sum()\n", + " plt.hist(n_guides)\n", + " plt.title(\"Total edit rates per target\")" + ] }, { "cell_type": "markdown", From 13b5411cd8a10e2dbc073de18c52a3ab16a66a1c Mon Sep 17 00:00:00 2001 From: Jayoung Ryu Date: Sat, 25 Nov 2023 19:54:00 -0500 Subject: [PATCH 11/27] fix notebook --- notebooks/sample_quality_report.ipynb | 3 --- 1 file changed, 3 deletions(-) diff --git a/notebooks/sample_quality_report.ipynb b/notebooks/sample_quality_report.ipynb index 4d056ea..bf8bb10 100644 --- a/notebooks/sample_quality_report.ipynb +++ b/notebooks/sample_quality_report.ipynb @@ -284,8 +284,6 @@ " editable_base_end=edit_quantification_end_pos\n", " )\n", " be.qc.plot_sample_edit_rates(bdata)" -<<<<<<< Updated upstream -======= ] }, { @@ -323,7 +321,6 @@ " total_edits = bdata.guides.groupby(\"target\")[\"edit_rate\"].sum()\n", " plt.hist(n_guides)\n", " plt.title(\"Total edit rates per target\")" ->>>>>>> Stashed changes ] }, { From 3a3f96df8783b74329394a0009672589f4b815f5 Mon Sep 17 00:00:00 2001 From: Jayoung Ryu Date: Sat, 25 Nov 2023 20:15:58 -0500 Subject: [PATCH 12/27] fix notebook --- notebooks/sample_quality_report.ipynb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/notebooks/sample_quality_report.ipynb b/notebooks/sample_quality_report.ipynb index bf8bb10..40dd70f 100644 --- a/notebooks/sample_quality_report.ipynb +++ b/notebooks/sample_quality_report.ipynb @@ -58,7 +58,8 @@ "comp_cond2 = \"bot\"\n", "ctrl_cond = \"bulk\"\n", "recalculate_edits = False\n", - "tiling = None" + "tiling = None\n", + "base_edit_data = True" ] }, { From 34b440439388b70c8da428adc745b27094297e15 Mon Sep 17 00:00:00 2001 From: Jayoung Ryu Date: Sat, 25 Nov 2023 20:23:11 -0500 Subject: [PATCH 13/27] debug condition rep --- notebooks/sample_quality_report.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/notebooks/sample_quality_report.ipynb b/notebooks/sample_quality_report.ipynb index bf632b3..9b8f03c 100644 --- a/notebooks/sample_quality_report.ipynb +++ b/notebooks/sample_quality_report.ipynb @@ -387,8 +387,8 @@ " replicate_label\n", " ].values.tolist()\n", " samples_include = ~bdata_filtered.samples[\"_rc\"].isin(rep_has_too_small_sample)\n", - "bdata_filtered = bdata_filtered[:, samples_include]\n", - "bdata_filtered.samples.pop(\"_rc\")" + " bdata_filtered.samples.pop(\"_rc\")\n", + "bdata_filtered = bdata_filtered[:, samples_include]" ] }, { From e1caefce745027dcb0365e4a034513a6f6138526 Mon Sep 17 00:00:00 2001 From: Jayoung Ryu Date: Tue, 28 Nov 2023 10:42:58 -0500 Subject: [PATCH 14/27] clean up binary --- bean/model/run.py | 454 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 454 insertions(+) create mode 100644 bean/model/run.py diff --git a/bean/model/run.py b/bean/model/run.py new file mode 100644 index 0000000..a1619f4 --- /dev/null +++ b/bean/model/run.py @@ -0,0 +1,454 @@ +import os +import sys +import argparse +from tqdm import tqdm +import pickle as pkl +import pandas as pd +import logging +from functools import partial +import pyro +import bean.model.model as sorting_model +import bean.model.survival_model as survival_model + +logging.basicConfig( + level=logging.INFO, + format="%(levelname)-5s @ %(asctime)s:\n\t %(message)s \n", + datefmt="%a, %d %b %Y %H:%M:%S", + stream=sys.stderr, + filemode="w", +) +error = logging.critical +warn = logging.warning +debug = logging.debug +info = logging.info +pyro.set_rng_seed(101) + + +def none_or_str(value): + if value == "None": + return None + return value + + +def parse_args(): + print( + r""" + _ _ + / \ '\ + | \ \ _ _ _ _ _ _ + \ \ | | '_| || | ' \ + `.__|/ |_| \_,_|_||_| + """ + ) + print("bean-run: Run model to identify targeted variants and their impact.") + parser = argparse.ArgumentParser(description="Run model on data.") + parser.add_argument( + "selection", + type=str, + choices=["sorting", "survival"], + help="Screen selection type whether cells are sorted based on continuous phenotype ('sorting') or proliferated based on their viability ('survival').", + ) + parser.add_argument( + "library_design", + type=str, + choices=["variant", "tiling"], + help="Library design type whether to run variant or tiling screen model.\nVariant library design assumes gRNA has specific target variant and bystander edits are ignored. Tiling library design considers all alleles generated by gRNA in reporter.", + ) + parser.add_argument("bdata_path", type=str, help="Path of an ReporterScreen object") + parser.add_argument( + "--rep-pi", + "-r", + action="store_true", + default=False, + help="Fit replicate specific scaling factor. Recommended to set as True if you expect variable editing activity across biological replicates.", + ) + parser.add_argument( + "--uniform-edit", + "-p", + action="store_true", + default=False, + help="Assume uniform editing rate for all guides.", + ) + parser.add_argument( + "--scale-by-acc", + action="store_true", + default=False, + help="Scale guide editing efficiency by the target loci accessibility", + ) + parser.add_argument( + "--acc-bw-path", + type=str, + default=None, + help="Accessibility .bigWig file to be used to assign accessibility of guides.", + ) + parser.add_argument( + "--acc-col", + type=str, + default=None, + help="Column name in bdata.guides that specify raw ATAC-seq signal.", + ) + parser.add_argument( + "--const-pi", + default=False, + action="store_true", + help="Use constant pi provided in --guide-activity-col (instead of fitting from reporter data)", + ) + parser.add_argument( + "--shrink-alpha", + default=False, + action="store_true", + help="Instead of using the trend-fitted alpha values, use estimated alpha values for each gRNA that are shrunk towards the fitted trend.", + ) + parser.add_argument( + "--condition-col", + default="bin", + type=str, + help="Column key in `bdata.samples` that describes experimental condition.", + ) + parser.add_argument( + "--time-col", + default="time", + type=str, + help="Column key in `bdata.samples` that describes time elapsed.", + ) + parser.add_argument( + "--control-condition-label", + default="bulk", + type=str, + help="Value in `bdata.samples[condition_col]` that indicates control experimental condition.", + ) + parser.add_argument( + "--include-control-condition-for-inference", + "-ic", + default=False, + action="store_true", + help="Include control conditions for inference. Currently only supported for survival screens.", + ) + parser.add_argument( + "--replicate-col", + default="rep", + type=str, + help="Column key in `bdata.samples` that describes experimental replicates.", + ) + parser.add_argument( + "--target-col", + default="target", + type=str, + help="Column key in `bdata.guides` that describes the target element of each guide.", + ) + parser.add_argument( + "--guide-activity-col", + "-a", + type=str, + default=None, + help="Column in ReporterScreen.guides DataFrame showing the editing rate estimated via external tools", + ) + parser.add_argument( + "--outdir", + "-o", + default=".", + type=str, + help="Directory to save the run result.", + ) + parser.add_argument( + "--result-suffix", + default="", + type=str, + help="Suffix of the output files", + ) + parser.add_argument( + "--sorting-bin-upper-quantile-col", + "-uq", + help="Column name with upper quantile values of each sorting bin in [Reporter]Screen.samples (or AnnData.var)", + default="upper_quantile", + ) + parser.add_argument( + "--sorting-bin-lower-quantile-col", + "-lq", + help="Column name with lower quantile values of each sorting bin in [Reporter]Screen.samples (or AnnData var)", + default="lower_quantile", + ) + parser.add_argument( + "--alpha-if-overdispersion-fitting-fails", + "-af", + default=None, + type=str, + help="Comma-separated regression coefficient (b0, b1) of log(a0) ~ log(q) that will be used if fitting dispersion on the data fails.", + ) + parser.add_argument("--cuda", action="store_true", default=False, help="run on GPU") + parser.add_argument( + "--sample-mask-col", + type=str, + default=None, + help="Name of the column indicating the sample mask in [Reporter]Screen.samples (or AnnData.var). Sample is ignored if the value in this column is 0. This can be used to mask out low-quality samples.", + ) + parser.add_argument( + "--fit-negctrl", + action="store_true", + default=False, + help="Fit the shared negative control distribution to normalize the fitted parameters", + ) + parser.add_argument( + "--negctrl-col", + type=str, + default="target_group", + help="Column in bdata.obs specifying if a guide is negative control. If the `bdata.guides[negctrl_col].lower() == negctrl_col_value`, it is treated as negative control guide.", + ) + parser.add_argument( + "--negctrl-col-value", + type=str, + default="negctrl", + help="Column value in bdata.guides specifying if a guide is negative control. If the `bdata.guides[negctrl_col].lower() == negctrl_col_value`, it is treated as negative control guide.", + ) + parser.add_argument( + "--repguide-mask", + type=none_or_str, + default="repguide_mask", + help="n_replicate x n_guide mask to mask the outlier guides. screen.uns[repguide_mask] will be used.", + ) + parser.add_argument( + "--device", + type=str, + default=None, + help="Optionally use GPU if provided valid GPU device name (ex. cuda:0)", + ) + parser.add_argument( + "--ignore-bcmatch", + action="store_true", + default=False, + help="If provided, even if the screen object has .X_bcmatch, ignore the count when fitting.", + ) + parser.add_argument( + "--allele-df-key", + type=str, + default=None, + help="screen.uns[allele_df_key] will be used as the allele count.", + ) + parser.add_argument( + "--splice-site-path", + type=str, + default=None, + help="Path to splicing site", + ) + parser.add_argument( + "--control-guide-tag", + type=none_or_str, + default="CONTROL", + help="If this string is in guide name, treat each guide separately not to mix the position. Used for negative controls.", + ) + parser.add_argument( + "--dont-fit-noise", # TODO: add check args + action="store_true", + ) + parser.add_argument( + "--dont-adjust-confidence-by-negative-control", + action="store_true", + help="Adjust confidence by negative controls. For variant library_design, this uses negative control variants. For tiling library_design, adjusts confidence by synonymous edits.", + ) + parser.add_argument( + "--n-iter", # TODO: add check args + type=int, + default=2000, + help="# of SVI steps taken for inference.", + ) + parser.add_argument( + "--load-existing", # TODO: add check args + action="store_true", + help="Load existing .pkl file if present.", + ) + + return parser.parse_args() + + +def check_args(args, bdata): + args.adjust_confidence_by_negative_control = ( + not args.dont_adjust_confidence_by_negative_control + ) + if args.scale_by_acc: + if args.acc_col is None and args.acc_bw_path is None: + raise ValueError( + "--scale-by-acc not accompanied by --acc-col nor --acc-bw-path to use. Pass either one." + ) + elif args.acc_col is not None and args.acc_bw_path is not None: + warn( + "Both --acc-col and --acc-bw-path is specified. --acc-bw-path is ignored." + ) + args.acc_bw_path = None + if args.outdir is None: + args.outdir = os.path.dirname(args.bdata_path) + if args.library_design == "variant": + pass + elif args.library_design == "tiling": + if args.allele_df_key is None: + raise ValueError( + "--allele-df-key not provided for tiling screen. Feed in the key then allele counts in screen.uns[allele_df_key] will be used." + ) + else: + raise ValueError( + "Invalid library_design provided. Select either 'variant' or 'tiling'." + ) # TODO: change this into discrete modes via argparse + if args.fit_negctrl: + n_negctrl = ( + bdata.guides[args.negctrl_col].map(lambda s: s.lower()) + == args.negctrl_col_value.lower() + ).sum() + if not n_negctrl >= 20: + raise ValueError( + f"Not enough negative control guide in the input data: {n_negctrl}. Check your input arguments." + ) + if args.repguide_mask is not None and args.repguide_mask not in bdata.uns.keys(): + bdata.uns[args.repguide_mask] = pd.DataFrame( + index=bdata.guides.index, columns=bdata.samples[args.replicate_col].unique() + ).fillna(1) + warn( + f"{args.bdata_path} does not have replicate x guide outlier mask. All guides are included in analysis." + ) + if args.sample_mask_col is not None: + if args.sample_mask_col not in bdata.samples.columns.tolist(): + raise ValueError( + f"{args.bdata_path} does not have specified sample mask column {args.sample_mask_col} in .samples" + ) + if args.alpha_if_overdispersion_fitting_fails is not None: + try: + b0, b1 = args.alpha_if_overdispersion_fitting_fails.split(",") + args.popt = (float(b0), float(b1)) + except TypeError as e: + raise e( + f"Input --alpha-if-overdispersion-fitting-fails {args.alpha_if_overdispersion_fitting_fails} is malformatted! Provide [float].[float] format." + ) + else: + args.popt = None + return args, bdata + + +def _get_guide_target_info(bdata, args, cols_include=[]): + guide_info = bdata.guides.copy() + target_info = ( + guide_info[ + [args.target_col] + + [ + col + for col in guide_info.columns + if ( + ( + (col.startswith("target_")) + and len(guide_info[[args.target_col, col]].drop_duplicates()) + == len(guide_info[args.target_col].unique()) + ) + or col in cols_include + ) + and col != args.target_col + ] + ] + .drop_duplicates() + .set_index(args.target_col, drop=True) + ) + target_info["n_guides"] = guide_info.groupby("target").size() + + if "edit_rate" in guide_info.columns.tolist(): + edit_rate_info = ( + guide_info[[args.target_col, "edit_rate"]] + .groupby(args.target_col, sort=False) + .agg({"edit_rate": ["mean", "std"]}) + ) + edit_rate_info.columns = edit_rate_info.columns.get_level_values(1) + edit_rate_info = edit_rate_info.rename( + columns={"mean": "edit_rate_mean", "std": "edit_rate_std"} + ) + target_info = target_info.join(edit_rate_info) + return target_info + + +def run_inference( + model, guide, data, initial_lr=0.01, gamma=0.1, num_steps=2000, autoguide=False +): + pyro.clear_param_store() + lrd = gamma ** (1 / num_steps) + svi = pyro.infer.SVI( + model=model, + guide=guide, + optim=pyro.optim.ClippedAdam({"lr": initial_lr, "lrd": lrd}), + loss=pyro.infer.Trace_ELBO(), + ) + losses = [] + try: + for t in tqdm(range(num_steps)): + loss = svi.step(data) + if t % 100 == 0: + print(f"loss {loss} @ iter {t}") + losses.append(loss) + except ValueError as exc: + error( + "Error occurred during fitting. Saving temporary output at tmp_result.pkl." + ) + with open("tmp_result.pkl", "wb") as handle: + pkl.dump({"param": pyro.get_param_store()}, handle) + + raise ValueError( + f"Fitting halted for command: {' '.join(sys.argv)} with following error: \n {exc}" + ) + return { + "loss": losses, + "params": pyro.get_param_store(), + } + + +def identify_model_guide(args): + if args.selection == "sorting": + m = sorting_model + else: + m = survival_model + if args.library_design == "tiling": + info("Using Mixture Normal model...") + return ( + f"MultiMixtureNormal{'+Acc' if args.scale_by_acc else ''}", + partial( + m.MultiMixtureNormalModel, + scale_by_accessibility=args.scale_by_acc, + use_bcmatch=(not args.ignore_bcmatch,), + ), + partial( + m.MultiMixtureNormalGuide, + scale_by_accessibility=args.scale_by_acc, + fit_noise=~args.dont_fit_noise, + ), + ) + if args.uniform_edit: + if args.guide_activity_col is not None: + raise ValueError( + "Can't use the guide activity column while constraining uniform edit." + ) + info("Using Normal model...") + return ( + "Normal", + partial(m.NormalModel, use_bcmatch=(not args.ignore_bcmatch)), + m.NormalGuide, + ) + elif args.const_pi: + if args.guide_activity_col is not None: + raise ValueError( + "--guide-activity-col to be used as constant pi is not provided." + ) + info("Using Mixture Normal model with constant weight ...") + return ( + "MixtureNormalConstPi", + partial(m.MixtureNormalConstPiModel, use_bcmatch=(not args.ignore_bcmatch)), + m.MixtureNormalGuide, + ) + else: + info( + f"Using Mixture Normal model {'with accessibility normalization' if args.scale_by_acc else ''}..." + ) + return ( + f"{'_' if args.dont_fit_noise else ''}MixtureNormal{'+Acc' if args.scale_by_acc else ''}", + partial( + m.MixtureNormalModel, + scale_by_accessibility=args.scale_by_acc, + use_bcmatch=(not args.ignore_bcmatch,), + ), + partial( + m.MixtureNormalGuide, + scale_by_accessibility=args.scale_by_acc, + fit_noise=(not args.dont_fit_noise), + ), + ) From 27e3faf2f186e464546eacdf2176fd63029624d0 Mon Sep 17 00:00:00 2001 From: Jayoung Ryu Date: Tue, 28 Nov 2023 10:57:54 -0500 Subject: [PATCH 15/27] allow external popt --- bean/model/model.py | 20 ++++++++++---------- bean/preprocessing/data_class.py | 15 ++++++++------- bean/preprocessing/get_alpha0.py | 10 +++++++--- bin/bean-run | 5 ++++- notebooks/sample_quality_report.ipynb | 27 +++++++++++++-------------- tests/test_run.py | 12 ++++++------ 6 files changed, 48 insertions(+), 41 deletions(-) diff --git a/bean/model/model.py b/bean/model/model.py index 91514f1..96514ce 100644 --- a/bean/model/model.py +++ b/bean/model/model.py @@ -45,7 +45,7 @@ def NormalModel( sd = sd_alleles sd = torch.repeat_interleave(sd, data.target_lengths, dim=0) assert sd.shape == (data.n_guides, 1) - if data.sample_covariates is not None: + if hasattr(data, "sample_covariates"): with pyro.plate("cov_place", data.n_sample_covariates): mu_cov = pyro.sample("mu_cov", dist.Normal(0, 1)) assert mu_cov.shape == (data.n_sample_covariates,), mu_cov.shape @@ -55,15 +55,15 @@ def NormalModel( lq = data.lower_bounds[b] assert uq.shape == lq.shape == (data.n_condits,) with guide_plate: - mu = mu.unsqueeze(0).unsqueeze(0).expand( - (data.n_reps, data.n_condits, -1, -1) - ) + (data.rep_by_cov * mu_cov)[:, 0].unsqueeze(-1).unsqueeze( - -1 - ).unsqueeze( - -1 - ).expand( - (-1, data.n_condits, data.n_guides, 1) + mu = ( + mu.unsqueeze(0) + .unsqueeze(0) + .expand((data.n_reps, data.n_condits, -1, -1)) ) + if hasattr(data, "sample_covariates"): + mu = mu + (data.rep_by_cov * mu_cov)[:, 0].unsqueeze(-1).unsqueeze( + -1 + ).unsqueeze(-1).expand((-1, data.n_condits, data.n_guides, 1)) sd = torch.sqrt( ( sd.unsqueeze(0) @@ -511,7 +511,7 @@ def NormalGuide(data): constraint=constraints.positive, ) pyro.sample("sd_alleles", dist.LogNormal(sd_loc, sd_scale)) - if data.sample_covariates is not None: + if hasattr(data, "sample_covariates"): with pyro.plate("cov_place", data.n_sample_covariates): mu_cov_loc = pyro.param( "mu_cov_loc", torch.zeros((data.n_sample_covariates,)) diff --git a/bean/preprocessing/data_class.py b/bean/preprocessing/data_class.py index 505dab7..2ba381c 100644 --- a/bean/preprocessing/data_class.py +++ b/bean/preprocessing/data_class.py @@ -2,7 +2,7 @@ import abc import logging from dataclasses import dataclass -from typing import Dict, Tuple, List +from typing import Optional, Dict, Tuple, List from xmlrpc.client import Boolean from copy import deepcopy import torch @@ -46,7 +46,8 @@ def __init__( accessibility_bw_path: str = None, device: str = None, replicate_column: str = "rep", - pi_popt: Tuple[float] = None, + popt: Optional[Tuple[float]] = None, + pi_popt: Optional[Tuple[float]] = None, control_can_be_selected: bool = False, **kwargs, ): @@ -113,10 +114,9 @@ def __init__( self.sample_mask_column = sample_mask_column self.repguide_mask = repguide_mask self.shrink_alpha = shrink_alpha + self.popt = popt - def _post_init( - self, - ): + def _post_init(self): # Assign accessibility info if self.accessibility_col is not None: self.guide_accessibility = torch.as_tensor( @@ -185,6 +185,7 @@ def _post_init( self.size_factor.clone().cpu(), self.sample_mask.cpu(), shrink=self.shrink_alpha, + popt=self.popt, ) fitted_a0 = torch.as_tensor(fitted_a0) a0 = fitted_a0 @@ -926,7 +927,7 @@ def _pre_init( self.screen = _assign_rep_ids_and_sort( self.screen, self.replicate_column, self.condition_column ) - if self.sample_covariates is not None: + if hasattr(self, "sample_covariates"): self.rep_by_cov = torch.as_tensor( ( self.screen.samples[["_rc"] + self.sample_covariates] @@ -1013,7 +1014,7 @@ def _post_init( self.screen = _assign_rep_ids_and_sort( self.screen, self.replicate_column, self.time_column ) - if self.sample_covariates is not None: + if hasattr(self, "sample_covariates"): self.rep_by_cov = self.screen.samples.groupby(self.replicate_column)[ self.sample_covariates ].values diff --git a/bean/preprocessing/get_alpha0.py b/bean/preprocessing/get_alpha0.py index 31f3b03..f73f454 100644 --- a/bean/preprocessing/get_alpha0.py +++ b/bean/preprocessing/get_alpha0.py @@ -1,3 +1,4 @@ +from typing import Optional, Tuple import numpy as np import torch from scipy.optimize import curve_fit @@ -71,8 +72,9 @@ def get_fitted_alpha0( sample_size_factors, sample_mask=None, fit_quantile: float = None, - shrink=False, - shrink_prior_var=1.0, + shrink: bool = False, + shrink_prior_var: float = 1.0, + popt: Optional[Tuple[float, float]] = None, ): """Fits sum of concentration of DirichletMultinomial distribution. @@ -80,6 +82,7 @@ def get_fitted_alpha0( fit: if False, return the raw value fit_quantile: if not None, alpha is fitted conservatively with lowest `fit_quantile` guides. + popt: Regression coefficient (b0, b1) of log(a0) ~ log(q) that will be used if fitting dispersion on the data fails """ n_reps, n_condits, n_guides = X.shape if sample_mask is None: @@ -98,7 +101,8 @@ def get_fitted_alpha0( x, y = get_valid_vals(n.log(), a0.log()) if len(y) < 10: - popt = (-1.510, 0.7861) + if popt is None: + popt = (-1.510, 0.7861) print( f"Cannot fit log(a0) ~ log(q): data too sparse! Using pre-fitted values [b0, b1]={popt}" ) diff --git a/bin/bean-run b/bin/bean-run index 6bff913..810bbc5 100644 --- a/bin/bean-run +++ b/bin/bean-run @@ -84,6 +84,7 @@ def main(args, bdata): control_guide_tag=args.control_guide_tag, target_col=args.target_col, shrink_alpha=args.shrink_alpha, + popt=args.popt, replicate_col=args.replicate_col, use_bcmatch=(not args.ignore_bcmatch), ) @@ -181,7 +182,9 @@ def main(args, bdata): adjust_confidence_by_negative_control=args.adjust_confidence_by_negative_control, adjust_confidence_negatives=adj_negctrl_idx, sd_is_fitted=(args.selection == "sorting"), - sample_covariates=ndata.sample_covariates, + sample_covariates=ndata.sample_covariates + if hasattr(ndata, "sample_covariates") + else None, ) info("Done!") diff --git a/notebooks/sample_quality_report.ipynb b/notebooks/sample_quality_report.ipynb index 9b8f03c..7c18db9 100644 --- a/notebooks/sample_quality_report.ipynb +++ b/notebooks/sample_quality_report.ipynb @@ -286,17 +286,11 @@ " \"Not a base editing data or target base change not provided. Passing editing-related QC\"\n", " )\n", "elif \"edits\" in bdata.layers.keys():\n", - "\n", " bdata.get_guide_edit_rate(\n", - "\n", " editable_base_start=edit_quantification_start_pos,\n", - "\n", " editable_base_end=edit_quantification_end_pos,\n", - "\n", " unsorted_condition_label=ctrl_cond,\n", - "\n", " )\n", - "\n", " be.qc.plot_guide_edit_rates(bdata)" ] }, @@ -311,14 +305,10 @@ " \"Not a base editing data or target base change not provided. Passing editing-related QC\"\n", " )\n", "elif \"edits\" in bdata.layers.keys():\n", - "\n", " bdata.get_edit_rate(\n", " editable_base_start=edit_quantification_start_pos,\n", - "\n", " editable_base_end=edit_quantification_end_pos,\n", - "\n", " )\n", - "\n", " be.qc.plot_sample_edit_rates(bdata)" ] }, @@ -355,13 +345,22 @@ "source": [ "bdata.samples[\"mask\"] = 1\n", "bdata.samples.loc[\n", - " bdata.samples.median_corr_X.isnull() | (bdata.samples.median_corr_X < corr_X_thres), \"mask\"\n", + " bdata.samples.median_corr_X.isnull() | (bdata.samples.median_corr_X < corr_X_thres),\n", + " \"mask\",\n", "] = 0\n", "if \"median_editing_rate\" in bdata.samples.columns.tolist():\n", " bdata.samples.loc[bdata.samples.median_editing_rate < edit_rate_thres, \"mask\"] = 0\n", - "bdata_filtered = bdata[\n", - " :, bdata.samples[f\"median_lfc_corr.{comp_cond1}_{comp_cond2}\"] > lfc_thres\n", - "]" + "if (\n", + " isinstance(replicate_label, str)\n", + " and len(bdata.samples[replicate_label].unique()) > 1\n", + " or isinstance(replicate_label, list)\n", + " and len(bdata.samples[replicate_label].drop_duplicates()) > 1\n", + "):\n", + " bdata_filtered = bdata[\n", + " :, bdata.samples[f\"median_lfc_corr.{comp_cond1}_{comp_cond2}\"] > lfc_thres\n", + " ]\n", + "else:\n", + " bdata_filtered = bdata" ] }, { diff --git a/tests/test_run.py b/tests/test_run.py index 8d39cc4..baa0a80 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -4,7 +4,7 @@ @pytest.mark.order(13) def test_run_variant_wacc(): - cmd = "bean-run variant tests/data/var_mini_screen_annotated.h5ad --scale-by-acc --acc-bw-path tests/data/accessibility_signal_chr6.bw -o tests/test_res/var/ --repguide-mask None" + cmd = "bean-run sorting variant tests/data/var_mini_screen_annotated.h5ad --scale-by-acc --acc-bw-path tests/data/accessibility_signal_chr6.bw -o tests/test_res/var/ --repguide-mask None" try: subprocess.check_output( cmd, @@ -17,7 +17,7 @@ def test_run_variant_wacc(): @pytest.mark.order(14) def test_run_variant_noacc(): - cmd = "bean-run variant tests/data/var_mini_screen_annotated.h5ad -o tests/test_res/var/ " + cmd = "bean-run sorting variant tests/data/var_mini_screen_annotated.h5ad -o tests/test_res/var/ " try: subprocess.check_output( cmd, @@ -30,7 +30,7 @@ def test_run_variant_noacc(): @pytest.mark.order(15) def test_run_variant_wo_negctrl_uniform(): - cmd = "bean-run variant tests/data/var_mini_screen_annotated.h5ad -o tests/test_res/var/ --uniform-edit " + cmd = "bean-run sorting variant tests/data/var_mini_screen_annotated.h5ad -o tests/test_res/var/ --uniform-edit " try: subprocess.check_output( cmd, @@ -43,7 +43,7 @@ def test_run_variant_wo_negctrl_uniform(): @pytest.mark.order(16) def test_run_tiling_wo_negctrl(): - cmd = "bean-run tiling tests/data/tiling_mini_screen_annotated.h5ad --scale-by-acc --acc-bw-path tests/data/accessibility_signal.bw -o tests/test_res/tiling/ --allele-df-key allele_counts_spacer_0_19_A.G_translated_prop0.1_0.3 --control-guide-tag None --repguide-mask None" + cmd = "bean-run sorting tiling tests/data/tiling_mini_screen_annotated.h5ad --scale-by-acc --acc-bw-path tests/data/accessibility_signal.bw -o tests/test_res/tiling/ --allele-df-key allele_counts_spacer_0_19_A.G_translated_prop0.1_0.3 --control-guide-tag None --repguide-mask None" try: subprocess.check_output( cmd, @@ -56,7 +56,7 @@ def test_run_tiling_wo_negctrl(): @pytest.mark.order(17) def test_run_tiling_with_wo_negctrl_noacc(): - cmd = "bean-run tiling tests/data/tiling_mini_screen_annotated.h5ad -o tests/test_res/tiling/ --allele-df-key allele_counts_spacer_0_19_A.G_translated_prop0.1_0.3 --control-guide-tag None --repguide-mask None" + cmd = "bean-run sorting tiling tests/data/tiling_mini_screen_annotated.h5ad -o tests/test_res/tiling/ --allele-df-key allele_counts_spacer_0_19_A.G_translated_prop0.1_0.3 --control-guide-tag None --repguide-mask None" try: subprocess.check_output( cmd, @@ -69,7 +69,7 @@ def test_run_tiling_with_wo_negctrl_noacc(): @pytest.mark.order(18) def test_run_tiling_with_wo_negctrl_uniform(): - cmd = "bean-run tiling tests/data/tiling_mini_screen_annotated.h5ad -o tests/test_res/tiling/ --uniform-edit --allele-df-key allele_counts_spacer_0_19_A.G_translated_prop0.1_0.3 --control-guide-tag None --repguide-mask None" + cmd = "bean-run sorting tiling tests/data/tiling_mini_screen_annotated.h5ad -o tests/test_res/tiling/ --uniform-edit --allele-df-key allele_counts_spacer_0_19_A.G_translated_prop0.1_0.3 --control-guide-tag None --repguide-mask None" try: subprocess.check_output( cmd, From 83686ea4057b5aa9ccfa70f477cb61eb0e17fd84 Mon Sep 17 00:00:00 2001 From: Jayoung Ryu Date: Tue, 28 Nov 2023 11:02:27 -0500 Subject: [PATCH 16/27] debug return --- bean/preprocessing/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bean/preprocessing/utils.py b/bean/preprocessing/utils.py index 44c6431..8445f13 100644 --- a/bean/preprocessing/utils.py +++ b/bean/preprocessing/utils.py @@ -47,6 +47,7 @@ def prepare_bdata(bdata: be.ReporterScreen, args, warn, prefix: str): f"Ignoring {n_no_support_targets} targets with 0 gRNA counts across all non-control samples. Ignored targets are written in {prefix}/no_support_targets.csv." ) return bdata + return bdata def _get_accessibility_single( From cb5da17cb071343aee822588cf84e7d468fd8c9e Mon Sep 17 00:00:00 2001 From: Jayoung Ryu Date: Tue, 28 Nov 2023 11:08:36 -0500 Subject: [PATCH 17/27] remove dependency on survival implementation --- bean/model/run.py | 3 ++- tests/data/tiling_mini_screen_annotated.h5ad | Bin 2131872 -> 2131872 bytes tests/data/var_mini_screen_annotated.h5ad | Bin 2921120 -> 2921120 bytes 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/bean/model/run.py b/bean/model/run.py index a1619f4..2af840c 100644 --- a/bean/model/run.py +++ b/bean/model/run.py @@ -8,7 +8,8 @@ from functools import partial import pyro import bean.model.model as sorting_model -import bean.model.survival_model as survival_model + +# import bean.model.survival_model as survival_model logging.basicConfig( level=logging.INFO, diff --git a/tests/data/tiling_mini_screen_annotated.h5ad b/tests/data/tiling_mini_screen_annotated.h5ad index 9eae70fb100ae7fe0694e0815de3b88d2cc67d08..7e58ea796e4f17aab1221d45a886d8e73c3d6424 100644 GIT binary patch delta 221 zcmb`&y%9rT0D$2W5j9fOYIO+d{*1jUz6bZ%IOc`BhSb4g)3*uwcW1iz80(aE6ZnAtGFG#SJm;cpyQF3{T`JP!38~ GVr8 zeD;6(6ols>j1VP8oCHZyq{)yaN1g&jN|dQkrAD0wOoCQl( Ntl6-A1>x@S{sA{VcKQGS From a7ea8ace725ad6e33a05de7bbbac613867483a10 Mon Sep 17 00:00:00 2001 From: Jayoung Ryu Date: Tue, 28 Nov 2023 11:09:13 -0500 Subject: [PATCH 18/27] remove dependency on survival implementation --- bean/model/run.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bean/model/run.py b/bean/model/run.py index 2af840c..68fc672 100644 --- a/bean/model/run.py +++ b/bean/model/run.py @@ -397,8 +397,8 @@ def run_inference( def identify_model_guide(args): if args.selection == "sorting": m = sorting_model - else: - m = survival_model + # else: + # m = survival_model if args.library_design == "tiling": info("Using Mixture Normal model...") return ( From 599376acc2187d10f6d7382539fb10cac2fcc2aa Mon Sep 17 00:00:00 2001 From: Jayoung Kim Ryu Date: Tue, 28 Nov 2023 11:30:34 -0500 Subject: [PATCH 19/27] Include codecov --- .github/workflows/CI.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 1075c23..313f853 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -34,3 +34,7 @@ jobs: run: | pip install pytest pytest --sparse-ordering + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} From 227944be12008fa60fa5dceb1266cedf0583db07 Mon Sep 17 00:00:00 2001 From: Jayoung Kim Ryu Date: Tue, 28 Nov 2023 11:55:45 -0500 Subject: [PATCH 20/27] Update CI.yml --- .github/workflows/CI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 313f853..0da743b 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -8,7 +8,7 @@ name: CI -on: [push] +on: [push, workflow_dispatch] permissions: contents: read From aa00aa4e3b9d6555bebf1c2f745b0cbf7635adca Mon Sep 17 00:00:00 2001 From: Jayoung Ryu Date: Tue, 28 Nov 2023 13:14:51 -0500 Subject: [PATCH 21/27] debug notebook on tiling option --- notebooks/sample_quality_report.ipynb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/notebooks/sample_quality_report.ipynb b/notebooks/sample_quality_report.ipynb index 40dd70f..a8d4281 100644 --- a/notebooks/sample_quality_report.ipynb +++ b/notebooks/sample_quality_report.ipynb @@ -78,7 +78,11 @@ "outputs": [], "source": [ "if tiling is not None:\n", - " bdata.uns['tiling'] = tiling" + " bdata.uns['tiling'] = tiling\n", + "elif 'tiling' in bdata.uns:\n", + " tiling = bdata.uns['tiling']\n", + "else:\n", + " raise ValueError(\"Ambiguous assignment if the screen is a tiling screen. Provide `--tiling=True` or `tiling=False`.\")" ] }, { @@ -318,7 +322,7 @@ " print(\n", " \"Not a base editing data or target base change not provided. Passing editing-related QC\"\n", " )\n", - "elif tiling:\n", + "elif not tiling:\n", " total_edits = bdata.guides.groupby(\"target\")[\"edit_rate\"].sum()\n", " plt.hist(n_guides)\n", " plt.title(\"Total edit rates per target\")" From 55d82eba4664495c25f85f3c9b4bccbce265253c Mon Sep 17 00:00:00 2001 From: Jayoung Ryu Date: Tue, 28 Nov 2023 13:22:51 -0500 Subject: [PATCH 22/27] removing redundant cells --- notebooks/sample_quality_report.ipynb | 37 --------------------------- 1 file changed, 37 deletions(-) diff --git a/notebooks/sample_quality_report.ipynb b/notebooks/sample_quality_report.ipynb index a8d4281..8d9378b 100644 --- a/notebooks/sample_quality_report.ipynb +++ b/notebooks/sample_quality_report.ipynb @@ -392,43 +392,6 @@ "bdata_filtered.samples.style.background_gradient(cmap=\"coolwarm_r\")" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 5. Variant coverage" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "if not tiling:\n", - " n_guides = bdata.guides.groupby(\"target\").size()\n", - " int_bins = np.arange(min(0.5, n_guides.min() - 0.5), n_guides.max() + 0.5, 1)\n", - " plt.hist(n_guides, bins=int_bins)\n", - " plt.xticks(np.arange(n_guides.min() - 1, n_guides.max() + 1, 1))\n", - " plt.title(\"# Guides per target\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "if \"target_base_change\" not in bdata.uns or not base_edit_data:\n", - " print(\n", - " \"Not a base editing data or target base change not provided. Passing editing-related QC\"\n", - " )\n", - "elif not tiling:\n", - " total_edits = bdata.guides.groupby(\"target\")[\"edit_rate\"].sum()\n", - " plt.hist(n_guides)\n", - " plt.title(\"Total edit rates per target\")" - ] - }, { "cell_type": "markdown", "metadata": {}, From 2bd9c2a314ba03ff799f30b8aaba105b7e756ad7 Mon Sep 17 00:00:00 2001 From: Jayoung Ryu Date: Tue, 28 Nov 2023 13:34:37 -0500 Subject: [PATCH 23/27] merge remote changes --- .github/workflows/CI.yml | 6 +- README.md | 8 +- bean/framework/ReporterScreen.py | 20 +- bean/model/model.py | 74 +++- bean/model/readwrite.py | 87 +++- bean/model/utils.py | 374 +---------------- bean/preprocessing/data_class.py | 398 +++++++++++++++---- bean/preprocessing/get_alpha0.py | 10 +- bean/preprocessing/utils.py | 39 +- bean/qc/guide_qc.py | 26 +- bean/qc/utils.py | 72 +++- bin/bean-qc | 1 + bin/bean-run | 157 +++----- notebooks/sample_quality_report.ipynb | 98 ++++- setup.py | 4 +- tests/data/tiling_mini_screen_annotated.h5ad | Bin 2131872 -> 0 bytes tests/data/var_mini_screen_annotated.h5ad | Bin 2921120 -> 0 bytes tests/test_run.py | 12 +- 18 files changed, 733 insertions(+), 653 deletions(-) delete mode 100644 tests/data/tiling_mini_screen_annotated.h5ad delete mode 100644 tests/data/var_mini_screen_annotated.h5ad diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 1075c23..0da743b 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -8,7 +8,7 @@ name: CI -on: [push] +on: [push, workflow_dispatch] permissions: contents: read @@ -34,3 +34,7 @@ jobs: run: | pip install pytest pytest --sparse-ordering + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/README.md b/README.md index 1e68850..2504b2e 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ File should contain following columns with header. * `R1_filepath`: Path to read 1 `.fastq[.gz]` file * `R2_filepath`: Path to read 1 `.fastq[.gz]` file * `sample_id`: ID of sequencing sample -* `rep [Optional]`: Replicate # of this sample +* `rep [Optional]`: Replicate # of this sample (Should NOT contain `.`) * `bin [Optional]`: Name of the sorting bin * `upper_quantile [Optional]`: FACS sorting upper quantile * `lower_quantile [Optional]`: FACS sorting lower quantile @@ -201,6 +201,8 @@ Above command produces * `--tiling` (default: `None`): If set as `True` or `False`, it sets the screen object to be tiling (`True`) or variant (`False`)-targeting screen when calculating editing rate. * `--replicate-label` (default: `"rep"`): Label of column in `bdata.samples` that describes replicate ID. * `--condition-label` (default: `"bin"`)": Label of column in `bdata.samples` that describes experimental condition. (sorting bin, time, etc.). +* `--sample-covariates` (default: `None`): Comma-separated list of column names in `bdata.samples` that describes non-selective experimental condition (drug treatment, etc.). The values in the `bdata.samples` should NOT contain `.`. +* `--no-editing` (default: `False`): Ignore QC about editing. Can be used for QC of other editing modalities. * `--target-pos-col` (default: `"target_pos"`): Target position column in `bdata.guides` specifying target edit position in reporter. * `--rel-pos-is-reporter` (default: `False`): Specifies whether `edit_start_pos` and `edit_end_pos` are relative to reporter position. If `False`, those are relative to spacer position. * `--edit-start-pos` (default: `2`): Edit start position to quantify editing rate on, 0-based inclusive. @@ -279,7 +281,7 @@ bean-filter my_sorting_screen.h5ad \ ## `bean-run`: Quantify variant effects BEAN uses Bayesian network to incorporate gRNA editing outcome to provide posterior estimate of variant phenotype. The Bayesian network reflects data generation process. Briefly, -1. Cellular phenotype is modeled as the Gaussian mixture distribution of wild-type phenotype and variant phenotype. +1. Cellular phenotype (either for cells are sorted upon for sorting screen, or log(proliferation rate)) is modeled as the Gaussian mixture distribution of wild-type phenotype and variant phenotype. 2. The weight of the mixture components are inferred from the reporter editing outcome and the chromatin accessibility of the loci. 3. Cells with each gRNA, formulated as the mixture distribution, is sorted by the phenotypic quantile to produce the gRNA counts. @@ -289,7 +291,7 @@ For the full detail, see the method section of the [BEAN manuscript](https://www

```bash -bean-run variant[tiling] my_sorting_screen_filtered.h5ad \ +bean-run sorting[survival] variant[tiling] my_sorting_screen_filtered.h5ad \ [--uniform-edit, --scale-by-acc [--acc-bw-path accessibility_signal.bw, --acc-col accessibility]] \ -o output_prefix/ \ --fit-negctrl diff --git a/bean/framework/ReporterScreen.py b/bean/framework/ReporterScreen.py index 05eeb3d..37631d5 100644 --- a/bean/framework/ReporterScreen.py +++ b/bean/framework/ReporterScreen.py @@ -99,6 +99,8 @@ def __init__( self.layers["X_bcmatch"] = X_bcmatch for k, df in self.uns.items(): if not isinstance(df, pd.DataFrame): + if k == "sample_covariates" and not isinstance(df, list): + self.uns[k] = df.tolist() continue if "guide" in df.columns and len(df) > 0: if ( @@ -323,8 +325,20 @@ def __getitem__(self, index): new_uns = deepcopy(self.uns) for k, df in adata.uns.items(): if k.startswith("repguide_mask"): - new_uns[k] = df.loc[guides_include, adata.var.rep.unique()] + if "sample_covariates" in adata.uns: + adata.var["_rc"] = adata.var[ + ["rep"] + list(adata.uns["sample_covariates"]) + ].values.tolist() + adata.var["_rc"] = adata.var["_rc"].map( + lambda slist: ".".join(slist) + ) + new_uns[k] = df.loc[guides_include, adata.var._rc.unique()] + #adata.var.pop("_rc") + else: + new_uns[k] = df.loc[guides_include, adata.var.rep.unique()] if not isinstance(df, pd.DataFrame): + if k == "sample_covariates": + new_uns[k] = df continue if "guide" in df.columns: if "allele" in df.columns: @@ -892,7 +906,7 @@ def concat(screens: Collection[ReporterScreen], *args, axis=1, **kwargs): if axis == 0: for k in keys: - if k in ["target_base_change", "tiling"]: + if k in ["target_base_change", "tiling", "sample_covariates"]: adata.uns[k] = screens[0].uns[k] continue elif "edit" not in k and "allele" not in k: @@ -902,7 +916,7 @@ def concat(screens: Collection[ReporterScreen], *args, axis=1, **kwargs): if axis == 1: # If combining multiple samples, edit/allele tables should be merged. for k in keys: - if k in ["target_base_change", "tiling"]: + if k in ["target_base_change", "tiling", "sample_covariates"]: adata.uns[k] = screens[0].uns[k] continue elif "edit" not in k and "allele" not in k: diff --git a/bean/model/model.py b/bean/model/model.py index a5c3b66..96514ce 100644 --- a/bean/model/model.py +++ b/bean/model/model.py @@ -45,26 +45,51 @@ def NormalModel( sd = sd_alleles sd = torch.repeat_interleave(sd, data.target_lengths, dim=0) assert sd.shape == (data.n_guides, 1) - + if hasattr(data, "sample_covariates"): + with pyro.plate("cov_place", data.n_sample_covariates): + mu_cov = pyro.sample("mu_cov", dist.Normal(0, 1)) + assert mu_cov.shape == (data.n_sample_covariates,), mu_cov.shape with replicate_plate: with bin_plate as b: uq = data.upper_bounds[b] lq = data.lower_bounds[b] assert uq.shape == lq.shape == (data.n_condits,) - # with guide_plate, poutine.mask(mask=(data.allele_counts.sum(axis=-1) == 0)): with guide_plate: + mu = ( + mu.unsqueeze(0) + .unsqueeze(0) + .expand((data.n_reps, data.n_condits, -1, -1)) + ) + if hasattr(data, "sample_covariates"): + mu = mu + (data.rep_by_cov * mu_cov)[:, 0].unsqueeze(-1).unsqueeze( + -1 + ).unsqueeze(-1).expand((-1, data.n_condits, data.n_guides, 1)) + sd = torch.sqrt( + ( + sd.unsqueeze(0) + .unsqueeze(0) + .expand((data.n_reps, data.n_condits, -1, -1)) + ) + ) alleles_p_bin = get_std_normal_prob( - uq.unsqueeze(-1).unsqueeze(-1).expand((-1, data.n_guides, 1)), - lq.unsqueeze(-1).unsqueeze(-1).expand((-1, data.n_guides, 1)), - mu.unsqueeze(0).expand((data.n_condits, -1, -1)), - sd.unsqueeze(0).expand((data.n_condits, -1, -1)), + uq.unsqueeze(0) + .unsqueeze(-1) + .unsqueeze(-1) + .expand((data.n_reps, -1, data.n_guides, 1)), + lq.unsqueeze(0) + .unsqueeze(-1) + .unsqueeze(-1) + .expand((data.n_reps, -1, data.n_guides, 1)), + mu, + sd, ) - assert alleles_p_bin.shape == (data.n_condits, data.n_guides, 1) - - expected_allele_p = alleles_p_bin.unsqueeze(0).expand( - data.n_reps, -1, -1, -1 - ) - expected_guide_p = expected_allele_p.sum(axis=-1) + assert alleles_p_bin.shape == ( + data.n_reps, + data.n_condits, + data.n_guides, + 1, + ) + expected_guide_p = alleles_p_bin.sum(axis=-1) assert expected_guide_p.shape == ( data.n_reps, data.n_condits, @@ -91,7 +116,6 @@ def NormalModel( obs=data.X_masked.permute(0, 2, 1), ) if use_bcmatch: - print(f"Use_bcmatch:{use_bcmatch}") a_bcmatch = get_alpha( expected_guide_p, data.size_factor_bcmatch, @@ -159,14 +183,10 @@ def ControlNormalModel(data, mask_thres=10, use_bcmatch=True): with pyro.plate("guide_plate3", data.n_guides, dim=-1): a = get_alpha(expected_guide_p, data.size_factor, data.sample_mask, data.a0) - assert ( - data.X.shape - == data.X_bcmatch.shape - == ( - data.n_reps, - data.n_condits, - data.n_guides, - ) + assert data.X.shape == ( + data.n_reps, + data.n_condits, + data.n_guides, ) with poutine.mask( mask=torch.logical_and( @@ -491,6 +511,18 @@ def NormalGuide(data): constraint=constraints.positive, ) pyro.sample("sd_alleles", dist.LogNormal(sd_loc, sd_scale)) + if hasattr(data, "sample_covariates"): + with pyro.plate("cov_place", data.n_sample_covariates): + mu_cov_loc = pyro.param( + "mu_cov_loc", torch.zeros((data.n_sample_covariates,)) + ) + mu_cov_scale = pyro.param( + "mu_cov_scale", + torch.ones((data.n_sample_covariates,)), + constraint=constraints.positive, + ) + mu_cov = pyro.sample("mu_cov", dist.Normal(mu_cov_loc, mu_cov_scale)) + assert mu_cov.shape == (data.n_sample_covariates,), mu_cov.shape def MixtureNormalGuide( diff --git a/bean/model/readwrite.py b/bean/model/readwrite.py index fc34d11..c1c68cd 100644 --- a/bean/model/readwrite.py +++ b/bean/model/readwrite.py @@ -1,4 +1,4 @@ -from typing import Union, Sequence, Optional +from typing import Union, Sequence, Optional, List import numpy as np import pandas as pd from statistics import NormalDist @@ -57,42 +57,81 @@ def write_result_table( adjust_confidence_negatives: np.ndarray = None, guide_index: Optional[Sequence[str]] = None, guide_acc: Optional[Sequence] = None, + sd_is_fitted: bool = True, + sample_covariates: List[str] = None, return_result: bool = False, ) -> Union[pd.DataFrame, None]: """Combine target information and scores to write result table to a csv file or return it.""" if param_hist_dict["params"]["mu_loc"].dim() == 2: mu = param_hist_dict["params"]["mu_loc"].detach()[:, 0].cpu().numpy() mu_sd = param_hist_dict["params"]["mu_scale"].detach()[:, 0].cpu().numpy() - sd = param_hist_dict["params"]["sd_loc"].detach().exp()[:, 0].cpu().numpy() + if sd_is_fitted: + sd = param_hist_dict["params"]["sd_loc"].detach().exp()[:, 0].cpu().numpy() elif param_hist_dict["params"]["mu_loc"].dim() == 1: mu = param_hist_dict["params"]["mu_loc"].detach().cpu().numpy() mu_sd = param_hist_dict["params"]["mu_scale"].detach().cpu().numpy() - sd = param_hist_dict["params"]["sd_loc"].detach().exp().cpu().numpy() + if sd_is_fitted: + sd = param_hist_dict["params"]["sd_loc"].detach().exp().cpu().numpy() else: raise ValueError( f'`mu_loc` has invalid shape of {param_hist_dict["params"]["mu_loc"].shape}' ) - fit_df = pd.DataFrame( - { - "mu": mu, - "mu_sd": mu_sd, - "mu_z": mu / mu_sd, - "sd": sd, - } - ) + param_dict = { + "mu": mu, + "mu_sd": mu_sd, + "mu_z": mu / mu_sd, + } + if sd_is_fitted: + param_dict["sd"] = sd + if sample_covariates is not None: + assert ( + "mu_cov_loc" in param_hist_dict["params"] + and "mu_cov_scale" in param_hist_dict["params"] + ), param_hist_dict["params"].keys() + for i, sample_cov in enumerate(sample_covariates): + param_dict[f"mu_{sample_cov}"] = ( + mu + param_hist_dict["params"]["mu_cov_loc"].detach().cpu().numpy()[i] + ) + param_dict[f"mu_sd_{sample_cov}"] = np.sqrt( + mu_sd**2 + + param_hist_dict["params"]["mu_cov_scale"].detach().cpu().numpy()[i] + ** 2 + ) + param_dict[f"mu_z_{sample_cov}"] = ( + param_dict[f"mu_{sample_cov}"] / param_dict[f"mu_sd_{sample_cov}"] + ) + + fit_df = pd.DataFrame(param_dict) fit_df["novl"] = get_novl(fit_df, "mu", "mu_sd") if "negctrl" in param_hist_dict.keys(): print("Normalizing with common negative control distribution") mu0 = param_hist_dict["negctrl"]["params"]["mu_loc"].detach().cpu().numpy() - sd0 = ( - param_hist_dict["negctrl"]["params"]["sd_loc"].detach().exp().cpu().numpy() - ) - print(f"Fitted mu0={mu0}, sd0={sd0}.") + if sd_is_fitted: + sd0 = ( + param_hist_dict["negctrl"]["params"]["sd_loc"] + .detach() + .exp() + .cpu() + .numpy() + ) + print(f"Fitted mu0={mu0}" + (f", sd0={sd0}." if sd_is_fitted else "")) fit_df["mu_scaled"] = (mu - mu0) / sd0 fit_df["mu_sd_scaled"] = mu_sd / sd0 fit_df["mu_z_scaled"] = fit_df.mu_scaled / fit_df.mu_sd_scaled - fit_df["sd_scaled"] = sd / sd0 + if sd_is_fitted: + fit_df["sd_scaled"] = sd / sd0 fit_df["novl_scaled"] = get_novl(fit_df, "mu_scaled", "mu_sd_scaled") + if sample_covariates is not None: + for i, sample_cov in enumerate(sample_covariates): + fit_df[f"mu_{sample_cov}_scaled"] = ( + fit_df[f"mu_{sample_cov}"] - mu0 + ) / sd0 + fit_df[f"mu_sd_{sample_cov}_scaled"] = ( + fit_df[f"mu_sd_{sample_cov}"] / sd0 + ) + fit_df[f"mu_z_{sample_cov}_scaled"] = ( + fit_df[f"mu_{sample_cov}_scaled"] / fit_df["mu_sd_scaled"] + ) fit_df = pd.concat( [target_info_df.reset_index(), fit_df.reset_index(drop=True)], axis=1 @@ -123,6 +162,22 @@ def write_result_table( else "mu_sd", ) fit_df = add_credible_interval(fit_df, "mu_adj", "mu_sd_adj") + if sample_covariates is not None: + for i, sample_cov in enumerate(sample_covariates): + fit_df = adjust_normal_params_by_control( + fit_df, + std, + suffix=f"_{sample_cov}_adj", + mu_adjusted_col=f"mu_{sample_cov}_scaled" + if "negctrl" in param_hist_dict.keys() + else f"mu_{sample_cov}", + mu_sd_adjusted_col=f"mu_sd_{sample_cov}_scaled" + if "negctrl" in param_hist_dict.keys() + else f"mu_sd_{sample_cov}", + ) + fit_df = add_credible_interval( + fit_df, f"mu_{sample_cov}_adj", f"mu_sd_{sample_cov}_adj" + ) if write_fitted_eff or guide_acc is not None: if "alpha_pi" not in param_hist_dict["params"].keys(): diff --git a/bean/model/utils.py b/bean/model/utils.py index 10f059c..7dfba0a 100644 --- a/bean/model/utils.py +++ b/bean/model/utils.py @@ -1,370 +1,30 @@ -import os -import sys -import argparse -from tqdm import tqdm -import logging -import pickle as pkl -import pandas as pd import torch import torch.distributions as tdist import pyro import pyro.distributions as dist import pyro.distributions.constraints as constraints -logging.basicConfig( - level=logging.INFO, - format="%(levelname)-5s @ %(asctime)s:\n\t %(message)s \n", - datefmt="%a, %d %b %Y %H:%M:%S", - stream=sys.stderr, - filemode="w", -) -error = logging.critical -warn = logging.warning -debug = logging.debug -info = logging.info -pyro.set_rng_seed(101) - - -def run_inference( - model, guide, data, initial_lr=0.01, gamma=0.1, num_steps=2000, autoguide=False -): - pyro.clear_param_store() - lrd = gamma ** (1 / num_steps) - svi = pyro.infer.SVI( - model=model, - guide=guide, - optim=pyro.optim.ClippedAdam({"lr": initial_lr, "lrd": lrd}), - loss=pyro.infer.Trace_ELBO(), - ) - losses = [] - try: - for t in tqdm(range(num_steps)): - loss = svi.step(data) - if t % 100 == 0: - print(f"loss {loss} @ iter {t}") - losses.append(loss) - except ValueError as exc: - error( - "Error occurred during fitting. Saving temporary output at tmp_result.pkl." - ) - with open("tmp_result.pkl", "wb") as handle: - pkl.dump({"param": pyro.get_param_store()}, handle) - - raise ValueError( - f"Fitting halted for command: {' '.join(sys.argv)} with following error: \n {exc}" - ) - return { - "loss": losses, - "params": pyro.get_param_store(), - } - - -def _get_guide_target_info(bdata, args, cols_include=[]): - guide_info = bdata.guides.copy() - target_info = ( - guide_info[ - [args.target_col] - + [ - col - for col in guide_info.columns - if ( - ( - (col.startswith("target_")) - and len(guide_info[[args.target_col, col]].drop_duplicates()) - == len(guide_info[args.target_col].unique()) - ) - or col in cols_include - ) - and col != args.target_col - ] - ] - .drop_duplicates() - .set_index(args.target_col, drop=True) - ) - if "edit_rate" in guide_info.columns.tolist(): - edit_rate_info = ( - guide_info[[args.target_col, "edit_rate"]] - .groupby(args.target_col, sort=False) - .agg({"edit_rate": ["mean", "std"]}) - ) - edit_rate_info.columns = edit_rate_info.columns.get_level_values(1) - edit_rate_info = edit_rate_info.rename( - columns={"mean": "edit_rate_mean", "std": "edit_rate_std"} - ) - target_info = target_info.join(edit_rate_info) - return target_info - - -def none_or_str(value): - if value == "None": - return None - return value - - -def parse_args(): - print( - r""" - _ _ - / \ '\ - | \ \ _ _ _ _ _ _ - \ \ | | '_| || | ' \ - `.__|/ |_| \_,_|_||_| - """ - ) - print("bean-run: Run model to identify targeted variants and their impact.") - parser = argparse.ArgumentParser(description="Run model on data.") - - parser.add_argument( - "mode", - type=str, - help="[variant, tiling]- Screen type whether to run variant or tiling screen model.", - ) - parser.add_argument("bdata_path", type=str, help="Path of an ReporterScreen object") - parser.add_argument( - "--rep-pi", - "-r", - action="store_true", - default=False, - help="Fit replicate specific scaling factor. Recommended to set as True if you expect variable editing activity across biological replicates.", - ) - parser.add_argument( - "--uniform-edit", - "-p", - action="store_true", - default=False, - help="Assume uniform editing rate for all guides.", - ) - parser.add_argument( - "--scale-by-acc", - action="store_true", - default=False, - help="Scale guide editing efficiency by the target loci accessibility", - ) - parser.add_argument( - "--acc-bw-path", - type=str, - default=None, - help="Accessibility .bigWig file to be used to assign accessibility of guides.", - ) - parser.add_argument( - "--acc-col", - type=str, - default=None, - help="Column name in bdata.guides that specify raw ATAC-seq signal.", - ) - parser.add_argument( - "--const-pi", - default=False, - action="store_true", - help="Use constant pi provided in --guide-activity-col (instead of fitting from reporter data)", - ) - parser.add_argument( - "--shrink-alpha", - default=False, - action="store_true", - help="Instead of using the trend-fitted alpha values, use estimated alpha values for each gRNA that are shrunk towards the fitted trend.", - ) - parser.add_argument( - "--condition-col", - default="bin", - type=str, - help="Column key in `bdata.samples` that describes experimental condition.", - ) - parser.add_argument( - "--control-condition-label", - default="bulk", - type=str, - help="Value in `bdata.samples[condition_col]` that indicates control experimental condition.", - ) - parser.add_argument( - "--replicate-col", - default="rep", - type=str, - help="Column key in `bdata.samples` that describes experimental replicates.", - ) - parser.add_argument( - "--target-col", - default="target", - type=str, - help="Column key in `bdata.guides` that describes the target element of each guide.", - ) - parser.add_argument( - "--guide-activity-col", - "-a", - type=str, - default=None, - help="Column in ReporterScreen.guides DataFrame showing the editing rate estimated via external tools", - ) - parser.add_argument( - "--outdir", - "-o", - default=".", - type=str, - help="Directory to save the run result.", - ) - parser.add_argument( - "--result-suffix", - default="", - type=str, - help="Suffix of the output files", - ) - parser.add_argument( - "--sorting-bin-upper-quantile-col", - "-uq", - help="Column name with upper quantile values of each sorting bin in [Reporter]Screen.samples (or AnnData.var)", - default="upper_quantile", - ) - parser.add_argument( - "--sorting-bin-lower-quantile-col", - "-lq", - help="Column name with lower quantile values of each sorting bin in [Reporter]Screen.samples (or AnnData var)", - default="lower_quantile", - ) - parser.add_argument("--cuda", action="store_true", default=False, help="run on GPU") - parser.add_argument( - "--sample-mask-col", - type=str, - default=None, - help="Name of the column indicating the sample mask in [Reporter]Screen.samples (or AnnData.var). Sample is ignored if the value in this column is 0. This can be used to mask out low-quality samples.", - ) - parser.add_argument( - "--fit-negctrl", - action="store_true", - default=False, - help="Fit the shared negative control distribution to normalize the fitted parameters", - ) - parser.add_argument( - "--negctrl-col", - type=str, - default="target_group", - help="Column in bdata.obs specifying if a guide is negative control. If the `bdata.guides[negctrl_col].lower() == negctrl_col_value`, it is treated as negative control guide.", - ) - parser.add_argument( - "--negctrl-col-value", - type=str, - default="negctrl", - help="Column value in bdata.guides specifying if a guide is negative control. If the `bdata.guides[negctrl_col].lower() == negctrl_col_value`, it is treated as negative control guide.", - ) - parser.add_argument( - "--repguide-mask", - type=none_or_str, - default="repguide_mask", - help="n_replicate x n_guide mask to mask the outlier guides. screen.uns[repguide_mask] will be used.", - ) - parser.add_argument( - "--device", - type=str, - default=None, - help="Optionally use GPU if provided valid GPU device name (ex. cuda:0)", - ) - parser.add_argument( - "--ignore-bcmatch", - action="store_true", - default=False, - help="If provided, even if the screen object has .X_bcmatch, ignore the count when fitting.", - ) - parser.add_argument( - "--allele-df-key", - type=str, - default=None, - help="screen.uns[allele_df_key] will be used as the allele count.", - ) - parser.add_argument( - "--splice-site-path", - type=str, - default=None, - help="Path to splicing site", - ) - parser.add_argument( - "--control-guide-tag", - type=none_or_str, - default="CONTROL", - help="If this string is in guide name, treat each guide separately not to mix the position. Used for negative controls.", - ) - parser.add_argument( - "--dont-fit-noise", # TODO: add check args - action="store_true", - ) - parser.add_argument( - "--dont-adjust-confidence-by-negative-control", - action="store_true", - help="Adjust confidence by negative controls. For variant mode, this uses negative control variants. For tiling mode, adjusts confidence by synonymous edits.", - ) - parser.add_argument( - "--load-existing", # TODO: add check args - action="store_true", - help="Load existing .pkl file if present.", - ) - - return parser.parse_args() - - -def check_args(args, bdata): - args.adjust_confidence_by_negative_control = ( - not args.dont_adjust_confidence_by_negative_control - ) - if args.scale_by_acc: - if args.acc_col is None and args.acc_bw_path is None: - raise ValueError( - "--scale-by-acc not accompanied by --acc-col nor --acc-bw-path to use. Pass either one." - ) - elif args.acc_col is not None and args.acc_bw_path is not None: - warn( - "Both --acc-col and --acc-bw-path is specified. --acc-bw-path is ignored." - ) - args.acc_bw_path = None - if args.outdir is None: - args.outdir = os.path.dirname(args.bdata_path) - if args.mode == "variant": - pass - elif args.mode == "tiling": - if args.allele_df_key is None: - raise ValueError( - "--allele-df-key not provided for tiling screen. Feed in the key then allele counts in screen.uns[allele_df_key] will be used." - ) - else: - raise ValueError( - "Invalid mode provided. Select either 'variant' or 'tiling'." - ) # TODO: change this into discrete modes via argparse - if args.fit_negctrl: - n_negctrl = ( - bdata.guides[args.negctrl_col].map(lambda s: s.lower()) - == args.negctrl_col_value.lower() - ).sum() - if not n_negctrl >= 20: - raise ValueError( - f"Not enough negative control guide in the input data: {n_negctrl}. Check your input arguments." - ) - if args.repguide_mask is not None and args.repguide_mask not in bdata.uns.keys(): - bdata.uns[args.repguide_mask] = pd.DataFrame( - index=bdata.guides.index, columns=bdata.samples[args.replicate_col].unique() - ).fillna(1) - warn( - f"{args.bdata_path} does not have replicate x guide outlier mask. All guides are included in analysis." - ) - if args.sample_mask_col is not None: - if args.sample_mask_col not in bdata.samples.columns.tolist(): - raise ValueError( - f"{args.bdata_path} does not have specified sample mask column {args.sample_mask_col} in .samples" - ) - - return args, bdata - def get_alpha( expected_guide_p, size_factor, sample_mask, a0, epsilon=1e-5, normalize_by_a0=True ): - p = ( - expected_guide_p.permute(0, 2, 1) * size_factor[:, None, :] - ) # (n_reps, n_guides, n_bins) - if normalize_by_a0: - a = ( - (p + epsilon / p.shape[-1]) - / (p.sum(axis=-1)[:, :, None] + epsilon) - * a0[None, :, None] - ) - a = (a * sample_mask[:, None, :]).clamp(min=epsilon) - return a + try: + p = ( + expected_guide_p.permute(0, 2, 1) * size_factor[:, None, :] + ) # (n_reps, n_guides, n_bins) + + if normalize_by_a0: + a = ( + (p + epsilon / p.shape[-1]) + / (p.sum(axis=-1)[:, :, None] + epsilon) + * a0[None, :, None] + ) + a = (a * sample_mask[:, None, :]).clamp(min=epsilon) + return a + except: + print(size_factor.shape) + print(expected_guide_p.shape) + print(a0.shape) a = (p * sample_mask[:, None, :]).clamp(min=epsilon) return a diff --git a/bean/preprocessing/data_class.py b/bean/preprocessing/data_class.py index ca8ae90..961fc92 100644 --- a/bean/preprocessing/data_class.py +++ b/bean/preprocessing/data_class.py @@ -1,8 +1,8 @@ import sys import abc -from dataclasses import dataclass -from typing import Dict, Tuple import logging +from dataclasses import dataclass +from typing import Optional, Dict, Tuple, List from xmlrpc.client import Boolean from copy import deepcopy import torch @@ -13,7 +13,6 @@ from .get_pi_alpha0 import get_fitted_alpha0 as get_fitted_pi_alpha0 from .get_pi_alpha0 import get_pred_alpha0 as get_pred_pi_alpha0 from .utils import ( - Alias, get_accessibility_guides, get_edit_to_index_dict, _assign_rep_ids_and_sort, @@ -41,34 +40,62 @@ def __init__( sample_mask_column: str = None, shrink_alpha: bool = False, condition_column: str = "sort", + sample_covariate_column: List[str] = [], control_condition: str = "bulk", accessibility_col: str = None, accessibility_bw_path: str = None, device: str = None, replicate_column: str = "rep", - pi_popt: Tuple[float] = None, + popt: Optional[Tuple[float]] = None, + pi_popt: Optional[Tuple[float]] = None, + control_can_be_selected: bool = False, **kwargs, ): + """ + Args + condition_column: By default, a single condition column, but you can optionally inlcude sample covariate column + control_can_be_selected: If True, screen.samples[condition_column] == control_condition can also be included in effect size inference if its condition column is not NA (Currently only suppoted for prolifertion screens). + """ # TODO: remove replicate with too small number of (ex. only 1) sorting bin self.condition_column = condition_column self.device = device screen.samples["size_factor"] = self.get_size_factor(screen.X) if not ( - "rep" in screen.samples.columns + replicate_column in screen.samples.columns and condition_column in screen.samples.columns ): - screen.samples["rep"], screen.samples[condition_column] = zip( + screen.samples[replicate_column], screen.samples[condition_column] = zip( *screen.samples.index.map(lambda s: s.rsplit("_", 1)) ) if condition_column not in screen.samples.columns: screen.samples[condition_column] = screen.samples["index"].map( lambda s: s.split("_")[-1] ) - + if "sample_covariates" in screen.uns: + self.sample_covariates = screen.uns["sample_covariates"] + self.n_sample_covariates = len(self.sample_covariates) + screen.samples["_rc"] = screen.samples[ + [replicate_column] + self.sample_covariates + ].values.tolist() + screen.samples["_rc"] = screen.samples["_rc"].map( + lambda slist: ".".join(slist) + ) + self.rep_by_cov = torch.as_tensor( + ( + screen.samples[["_rc"] + self.sample_covariates] + .drop_duplicates() + .set_index("_rc") + .values.astype(int) + ) + ) + replicate_column = "_rc" self.screen = screen - self.screen_selected = screen[ - :, screen.samples[condition_column] != control_condition - ] + if not control_can_be_selected: + self.screen_selected = screen[ + :, screen.samples[condition_column] != control_condition + ] + else: + self.screen_selected = screen[:, ~screen.samples[condition_column].isnull()] self.n_condits = len( self.screen_selected.var[condition_column].unique() @@ -87,10 +114,9 @@ def __init__( self.sample_mask_column = sample_mask_column self.repguide_mask = repguide_mask self.shrink_alpha = shrink_alpha + self.popt = popt - def _post_init( - self, - ): + def _post_init(self): # Assign accessibility info if self.accessibility_col is not None: self.guide_accessibility = torch.as_tensor( @@ -137,7 +163,7 @@ def _post_init( ).all() assert ( self.screen_selected.uns[self.repguide_mask].columns - == self.screen_selected.samples.rep.unique() + == self.screen_selected.samples[self.replicate_column].unique() ).all() self.repguide_mask = ( torch.as_tensor(self.screen_selected.uns[self.repguide_mask].values.T) @@ -159,6 +185,7 @@ def _post_init( self.size_factor.clone().cpu(), self.sample_mask.cpu(), shrink=self.shrink_alpha, + popt=self.popt, ) fitted_a0 = torch.as_tensor(fitted_a0) a0 = fitted_a0 @@ -173,12 +200,22 @@ def __getitem__(self, guide_idx): ndata.X_masked = ndata.X_masked[:, :, guide_idx] ndata.X_control = ndata.X_control[:, :, guide_idx] ndata.repguide_mask = ndata.repguide_mask[:, guide_idx] + ndata.a0 = ndata.a0[guide_idx] return ndata def transform_data(self, X, n_bins=None): if n_bins is None: n_bins = self.n_condits - x = torch.as_tensor(X).T.reshape((self.n_reps, n_bins, self.n_guides)).float() + try: + x = ( + torch.as_tensor(X) + .T.reshape((self.n_reps, n_bins, self.n_guides)) + .float() + ) + except RuntimeError: + print((self.n_reps, n_bins, self.n_guides)) + print(X.shape) + exit(1) if self.device is not None: x = x.cuda() return x @@ -668,7 +705,7 @@ def transform_allele(self, adata, reindexed_df): allele_tensor = torch.empty( (self.n_reps, self.n_condits, self.n_guides, self.n_max_alleles), ) - if not self.device is None: + if self.device is not None: allele_tensor = allele_tensor.cuda() for i in range(self.n_reps): for j in range(self.n_condits): @@ -710,7 +747,7 @@ def transform_allele(self, adata, reindexed_df): try: assert (allele_tensor >= 0).all(), allele_tensor[allele_tensor < 0] - except: + except AssertionError: print("Allele tensor doesn't match condit_allele_df") return (allele_tensor, reindexed_df) return allele_tensor @@ -724,7 +761,7 @@ def transform_allele_control(self, adata, reindexed_df): allele_tensor = torch.empty( (self.n_reps, 1, self.n_guides, self.n_max_alleles), ) - if not self.device is None: + if self.device is not None: allele_tensor = allele_tensor.cuda() for i in range(self.n_reps): condit_idx = np.where(adata.samples.rep_id == i)[0] @@ -761,14 +798,10 @@ def transform_allele_control(self, adata, reindexed_df): self.n_guides, self.n_max_alleles, ) - try: - allele_tensor[i, 0, :, :] = torch.as_tensor(condit_allele_df.to_numpy()) - except: - print("Allele tensor doesn't match condit_allele_df") - return (allele_tensor, torch.as_tensor(condit_allele_df.to_numpy())) + allele_tensor[i, 0, :, :] = torch.as_tensor(condit_allele_df.to_numpy()) try: assert (allele_tensor >= 0).all(), allele_tensor[allele_tensor < 0] - except: + except AssertionError: print("Negative values in allele_tensor") return (allele_tensor, reindexed_df) return allele_tensor @@ -796,48 +829,6 @@ def get_allele_mask( mask[i, j + 1] = 1 return mask.bool() - def get_allele_to_edit_tensor( - self, - adata, - edits_to_index: Dict[str, int], - guide_allele_id_to_allele_df: pd.DataFrame, - ): - """ - Convert (guide, allele_id_for_guide) -> allele DataFrame into the tensor with shape (n_guides, n_max_alleles_per_guide, n_edits) tensor. - ----- - Arguments - edits_to_index (dict) -- Dictionary from edit (str) to unique index (int) - guide_allele_id_to_allele_df (pd.DataFrame) -- pd.DataFrame of (guide(str), allele_id_for_guide(int)) -> CodingNoncodingAllele - ----- - Returns - allele_edit_assignment (torch.Tensor) -- Binary tensor of shape (n_guides, n_max_alleles_per_guide, n_edits). - allele_edit_assignment(i, j, k) is 1 if jth allele of ith guide has kth edit. - """ - guide_allele_id_to_allele_df[ - "edits" - ] = guide_allele_id_to_allele_df.aa_allele.map( - lambda a: list(a.aa_allele.edits) + list(a.nt_allele.edits) - ) - guide_allele_id_to_allele_df = guide_allele_id_to_allele_df.reset_index() - guide_allele_id_to_allele_df[ - "edit_idx" - ] = guide_allele_id_to_allele_df.edits.map( - lambda es: [edits_to_index[e.get_abs_edit()] for e in es] - ) - guide_allele_id_to_edit_df = guide_allele_id_to_allele_df[ - ["guide", "allele_id_for_guide", "edit_idx"] - ].set_index(["guide", "allele_id_for_guide"]) - guide_allele_id_to_edit_df = guide_allele_id_to_edit_df.unstack( - level=1, fill_value=[] - ).reindex(adata.guides.index, fill_value=[]) - allele_edit_assignment = torch.zeros( - (len(adata.guides), self.n_max_alleles - 1, len(edits_to_index.keys())) - ) - for i in range(len(guide_allele_id_to_edit_df)): - for j in range(len(guide_allele_id_to_edit_df.columns)): - allele_edit_assignment[i, j, guide_allele_id_to_edit_df.iloc[i, j]] = 1 - return allele_edit_assignment - @dataclass class SortingScreenData(ScreenData): @@ -851,6 +842,7 @@ def __init__( sample_mask_column: str = None, shrink_alpha: bool = False, condition_column: str = "sort", + sample_covariate_column: List[str] = [], control_condition: str = "bulk", lower_quantile_column: str = "lower_quantile", upper_quantile_column: str = "upper_quantile", @@ -909,7 +901,7 @@ def _pre_init( == len(self.screen.samples[self.replicate_column].unique()) ).all(): raise ValueError( - "Not all replicate share same quantile bin definition. If you have missing bin data, add the sample and add 'mask' column in 'screen.samples'." + "Not all replicate share same quantile bin definition. If you have missing bin data, add the sample and add 'mask' column in 'screen.samples' or run `bean-qc` that automatically handles this." ) sorting_bins = self.screen_selected.samples.sort_values( [upper_quantile_column, lower_quantile_column] @@ -935,6 +927,15 @@ def _pre_init( self.screen = _assign_rep_ids_and_sort( self.screen, self.replicate_column, self.condition_column ) + if hasattr(self, "sample_covariates"): + self.rep_by_cov = torch.as_tensor( + ( + self.screen.samples[["_rc"] + self.sample_covariates] + .drop_duplicates() + .set_index("_rc") + .values.astype(int) + ) + ) self.screen_selected = _assign_rep_ids_and_sort( self.screen_selected, self.replicate_column, self.condition_column ) @@ -952,12 +953,14 @@ def __init__( repguide_mask: str = None, sample_mask_column: str = None, shrink_alpha: bool = False, - condition_column: str = "time", + condition_column: str = "condition", control_condition: str = "bulk", - accessibility_col: str = None, - accessibility_bw_path: str = None, + control_can_be_selected=True, + time_column: str = "time", + replicate_column: str = "rep", **kwargs, ): + self._pre_init(condition_column) super().__init__( screen=screen, repguide_mask=repguide_mask, @@ -965,14 +968,63 @@ def __init__( shrink_alpha=shrink_alpha, condition_column=condition_column, control_condition=control_condition, - accessibility_col=accessibility_col, - accessibility_bw_path=accessibility_bw_path, + control_can_be_selected=control_can_be_selected, **kwargs, ) + self._post_init() + + def _pre_init(self, time_column: str, condition_column: str): + self.time_column = time_column + if not np.issubdtype(self.screen.samples[time_column].dtype, np.number): + raise ValueError( + f"Invalid timepoint value({self.screen.samples[time_column]}) in screen.samples[{time_column}]: check input." + ) + + if not ( + self.screen.samples.groupby(condition_column).size() + == len(self.screen.samples[self.replicate_column].unique()) + ).all(): + raise ValueError( + f"Not all replicate share same timepoint definition. If you have missing bin data, add the sample and add 'mask' column in 'screen.samples', or run `bean-qc` that automatically handles this. \n{self.screen.samples}" + ) + + def _post_init( + self, + ): self.timepoints = torch.as_tensor( - self.screen.samples[condition_column].unique() + self.screen_selected.samples[self.time_column].unique() + ) + self.n_timepoints = self.n_condits + timepoints = self.screen_selected.samples.sort_values(self.time_column)[ + self.time_column + ].drop_duplicates() + if timepoints.isnull().any(): + raise ValueError( + f"NaN values in time points provided in input: {self.screen_selected.samples[self.time_column]}" + ) + for j, time in enumerate(timepoints): + self.screen_selected.samples.loc[ + self.screen_selected.samples[self.time_column] == time, + f"{self.time_column}_id", + ] = j + self.screen.samples[f"{self.time_column}_id"] = -1 + self.screen.samples.loc[ + self.screen_selected.samples.index, f"{self.time_column}_id" + ] = self.screen_selected.samples[f"{self.time_column}_id"] + self.screen = _assign_rep_ids_and_sort( + self.screen, self.replicate_column, self.time_column + ) + if hasattr(self, "sample_covariates"): + self.rep_by_cov = self.screen.samples.groupby(self.replicate_column)[ + self.sample_covariates + ].values + self.screen_selected = _assign_rep_ids_and_sort( + self.screen_selected, self.replicate_column, self.condition_column + ) + self.screen_control = _assign_rep_ids_and_sort( + self.screen_control, + self.replicate_column, ) - self.timepoints = Alias("n_condits") @dataclass @@ -985,7 +1037,7 @@ def __init__( pi_popt: Tuple[float] = None, impute_pi_popt: bool = False, shrink_alpha: bool = False, - condition_column: str = "time", + condition_column: str = "condition", control_condition: str = "bulk", accessibility_col: str = None, accessibility_bw_path: str = None, @@ -1036,8 +1088,8 @@ def __init__( screen, *args, sample_mask_column=sample_mask_column, - replicate_column="rep", - condition_column="bin", + replicate_column=replicate_column, + condition_column=condition_column, shrink_alpha=shrink_alpha, **kwargs, ) @@ -1110,8 +1162,8 @@ def __init__( screen, *args, sample_mask_column=sample_mask_column, - replicate_column="rep", - condition_column="bin", + replicate_column=replicate_column, + condition_column=condition_column, shrink_alpha=shrink_alpha, **kwargs, ) @@ -1159,8 +1211,8 @@ def __init__( screen, *args, sample_mask_column=sample_mask_column, - replicate_column="rep", - condition_column="bin", + replicate_column=replicate_column, + condition_column=condition_column, shrink_alpha=shrink_alpha, **kwargs, ) @@ -1186,6 +1238,188 @@ def __init__( ) +@dataclass +class VariantSurvivalScreenData(VariantScreenData, SurvivalScreenData): + def __init__( + self, + screen, + *args, + replicate_column="rep", + condition_column="condition", + time_column="time", + control_can_be_selected=True, + target_col="target", + sample_mask_column="mask", + shrink_alpha: bool = False, + use_bcmatch=False, + **kwargs, + ): + ScreenData.__init__( + self, + screen, + *args, + sample_mask_column=sample_mask_column, + replicate_column=replicate_column, + condition_column=condition_column, + time_column=time_column, + shrink_alpha=shrink_alpha, + control_can_be_selected=control_can_be_selected, + **kwargs, + ) + SurvivalScreenData._pre_init(self, time_column, condition_column) + ScreenData._post_init(self) + SurvivalScreenData._post_init(self) + VariantScreenData._post_init(self, target_col) + if use_bcmatch: + self.set_bcmatch( + screen, + ) + + def set_bcmatch(self, screen): + screen.samples["size_factor_bcmatch"] = self.get_size_factor( + screen.layers["X_bcmatch"] + ) + self.screen_selected.samples["size_factor_bcmatch"] = screen.samples.loc[ + self.screen_selected.samples.index, "size_factor_bcmatch" + ] + self.screen_control.samples["size_factor_bcmatch"] = screen.samples.loc[ + self.screen_control.samples.index, "size_factor_bcmatch" + ] + self.X_bcmatch = self.transform_data(self.screen_selected.layers["X_bcmatch"]) + self.X_bcmatch_masked = self.X_bcmatch * self.sample_mask[:, :, None] + self.X_bcmatch_control = self.transform_data( + self.screen_control.layers["X_bcmatch"], 1 + ) + self.X_bcmatch_control_masked = ( + self.X_bcmatch_control * self.bulk_sample_mask[:, None, None] + ) + self.size_factor_bcmatch = torch.as_tensor( + self.screen_selected.samples["size_factor_bcmatch"].to_numpy() + ).reshape(self.n_reps, self.n_condits) + self.size_factor_bcmatch_control = torch.as_tensor( + self.screen_control.samples["size_factor_bcmatch"].to_numpy() + ).reshape(self.n_reps, 1) + a0_bcmatch = get_pred_alpha0( + self.X_bcmatch.clone().cpu(), + self.size_factor_bcmatch.clone().cpu(), + self.popt, + self.sample_mask.cpu(), + ) + self.a0_bcmatch = torch.as_tensor(a0_bcmatch) + + @dataclass class VariantSurvivalReporterScreenData(VariantReporterScreenData, SurvivalScreenData): - pass + def __init__( + self, + screen, + *args, + replicate_column="rep", + condition_column="condition", + time_column="time", + control_can_be_selected=True, + target_col="target", + sample_mask_column="mask", + use_const_pi: bool = False, + impute_pi_popt: bool = False, + pi_prior_count: int = 10, + shrink_alpha: bool = False, + pi_popt: Tuple[float] = None, + **kwargs, + ): + ScreenData.__init__( + self, + screen, + *args, + sample_mask_column=sample_mask_column, + replicate_column=replicate_column, + condition_column=condition_column, + time_column=time_column, + shrink_alpha=shrink_alpha, + control_can_be_selected=control_can_be_selected, + **kwargs, + ) + SurvivalScreenData._pre_init(self, time_column, condition_column) + ScreenData._post_init(self) + SurvivalScreenData._post_init(self) + VariantScreenData._post_init(self, target_col) + ReporterScreenData._post_init( + self, + screen, + use_const_pi, + impute_pi_popt, + pi_prior_count, + shrink_alpha, + pi_popt, + ) + + +@dataclass +class TilingSurvivalReporterScreenData(TilingReporterScreenData, SurvivalScreenData): + def __init__( + self, + screen, + *args, + replicate_column="rep", + condition_column="condition", + time_column="time", + control_can_be_selected=True, + sample_mask_column="mask", + use_const_pi: bool = False, + impute_pi_popt: bool = False, + pi_prior_count: int = 10, + shrink_alpha: bool = False, + pi_popt: Tuple[float] = None, + allele_df_key: str = None, + allele_col: str = None, + control_guide_tag: str = None, + **kwargs, + ): + ScreenData.__init__( + self, + screen, + *args, + sample_mask_column=sample_mask_column, + replicate_column=replicate_column, + condition_column=condition_column, + time_column=time_column, + shrink_alpha=shrink_alpha, + control_can_be_selected=control_can_be_selected, + **kwargs, + ) + SurvivalScreenData._pre_init(self, time_column, condition_column) + ScreenData._post_init(self) + SurvivalScreenData._post_init(self) + TilingReporterScreenData._post_init( + self, + allele_df_key=allele_df_key, + control_guide_tag=control_guide_tag, + ) + ReporterScreenData._post_init( + self, + screen, + use_const_pi, + impute_pi_popt, + pi_prior_count, + shrink_alpha, + pi_popt, + ) + + +DATACLASS_DICT = { + "sorting": { + "Normal": VariantSortingScreenData, + "MixtureNormal": VariantSortingReporterScreenData, + "MixtureNormal+Acc": VariantSortingReporterScreenData, + "MixtureNormalConstPi": VariantSortingScreenData, + "MultiMixtureNormal": TilingSortingReporterScreenData, + "MultiMixtureNormal+Acc": TilingSortingReporterScreenData, + }, + "survival": { + "Normal": VariantSurvivalScreenData, + "MixtureNormal": VariantSurvivalReporterScreenData, + "MixtureNormal+Acc": VariantSurvivalReporterScreenData, + "MultiMixtureNormal": TilingSurvivalReporterScreenData, + "MultiMixtureNormal+Acc": TilingSurvivalReporterScreenData, + }, +} diff --git a/bean/preprocessing/get_alpha0.py b/bean/preprocessing/get_alpha0.py index 31f3b03..f73f454 100644 --- a/bean/preprocessing/get_alpha0.py +++ b/bean/preprocessing/get_alpha0.py @@ -1,3 +1,4 @@ +from typing import Optional, Tuple import numpy as np import torch from scipy.optimize import curve_fit @@ -71,8 +72,9 @@ def get_fitted_alpha0( sample_size_factors, sample_mask=None, fit_quantile: float = None, - shrink=False, - shrink_prior_var=1.0, + shrink: bool = False, + shrink_prior_var: float = 1.0, + popt: Optional[Tuple[float, float]] = None, ): """Fits sum of concentration of DirichletMultinomial distribution. @@ -80,6 +82,7 @@ def get_fitted_alpha0( fit: if False, return the raw value fit_quantile: if not None, alpha is fitted conservatively with lowest `fit_quantile` guides. + popt: Regression coefficient (b0, b1) of log(a0) ~ log(q) that will be used if fitting dispersion on the data fails """ n_reps, n_condits, n_guides = X.shape if sample_mask is None: @@ -98,7 +101,8 @@ def get_fitted_alpha0( x, y = get_valid_vals(n.log(), a0.log()) if len(y) < 10: - popt = (-1.510, 0.7861) + if popt is None: + popt = (-1.510, 0.7861) print( f"Cannot fit log(a0) ~ log(q): data too sparse! Using pre-fitted values [b0, b1]={popt}" ) diff --git a/bean/preprocessing/utils.py b/bean/preprocessing/utils.py index 6a95b8d..8445f13 100644 --- a/bean/preprocessing/utils.py +++ b/bean/preprocessing/utils.py @@ -3,8 +3,8 @@ import numpy as np import pyBigWig import pandas as pd -import anndata as ad import bean as be +from bean.qc.guide_qc import filter_no_info_target class Alias: @@ -21,6 +21,35 @@ def __set__(self, obj, value): setattr(obj, self.source_name, value) +def prepare_bdata(bdata: be.ReporterScreen, args, warn, prefix: str): + """Utility function for formatting bdata for bean-run""" + bdata = bdata.copy() + bdata.samples[args.replicate_col] = bdata.samples[args.replicate_col].astype( + "category" + ) + bdata.guides = bdata.guides.loc[:, ~bdata.guides.columns.duplicated()].copy() + if args.library_design == "variant": + if bdata.guides[args.target_col].isnull().any(): + raise ValueError( + f"Some target column (bdata.guides[{args.target_col}]) value is null. Check your input file." + ) + bdata = bdata[bdata.guides[args.target_col].argsort(), :] + n_no_support_targets, bdata = filter_no_info_target( + bdata, + condit_col=args.condition_col, + control_condition=args.control_condition_label, + target_col=args.target_col, + write_no_support_targets=True, + no_support_target_write_path=f"{prefix}/no_support_targets.csv", + ) + if n_no_support_targets > 0: + warn( + f"Ignoring {n_no_support_targets} targets with 0 gRNA counts across all non-control samples. Ignored targets are written in {prefix}/no_support_targets.csv." + ) + return bdata + return bdata + + def _get_accessibility_single( pos: int, track, @@ -191,10 +220,10 @@ def _assign_rep_ids_and_sort( sort_key = f"{rep_col}_id" else: sort_key = [f"{rep_col}_id", f"{condition_column}_id"] - screen = screen[ - :, - screen.samples.sort_values(sort_key).index, - ] + screen = screen[ + :, + screen.samples.sort_values(sort_key).index, + ] return screen diff --git a/bean/qc/guide_qc.py b/bean/qc/guide_qc.py index 309a775..1bbf379 100644 --- a/bean/qc/guide_qc.py +++ b/bean/qc/guide_qc.py @@ -22,15 +22,27 @@ def get_outlier_guides_and_mask( abs_RPM_thres: RPM threshold value that will be used to define outlier guides. """ outlier_guides = get_outlier_guides(bdata, condit_col, mad_z_thres, abs_RPM_thres) - outlier_guides[replicate_col] = bdata.samples.loc[ - outlier_guides["sample"], replicate_col - ].values - mask = pd.DataFrame( - index=bdata.guides.index, columns=bdata.samples[replicate_col].unique() - ).fillna(1) + if not isinstance(replicate_col, str): + outlier_guides["_rc"] = bdata.samples.loc[ + outlier_guides["sample"], replicate_col + ].values.tolist() + outlier_guides["_rc"] = outlier_guides["_rc"].map(lambda slist: ".".join(slist)) + else: + outlier_guides[replicate_col] = bdata.samples.loc[ + outlier_guides["sample"], replicate_col + ].values + if isinstance(replicate_col, str): + reps = bdata.samples[replicate_col].unique() + else: + reps = bdata.samples[replicate_col].drop_duplicates().to_records(index=False) + reps = [".".join(slist) for slist in reps] + mask = pd.DataFrame(index=bdata.guides.index, columns=reps).fillna(1) print(outlier_guides) for _, row in outlier_guides.iterrows(): - mask.loc[row["name"], row[replicate_col]] = 0 + mask.loc[ + row["name"], row[replicate_col if isinstance(replicate_col, str) else "_rc"] + ] = 0 + return outlier_guides, mask diff --git a/bean/qc/utils.py b/bean/qc/utils.py index 48945d0..f584783 100644 --- a/bean/qc/utils.py +++ b/bean/qc/utils.py @@ -1,4 +1,5 @@ import distutils +from typing import Union, List import numpy as np import pandas as pd from copy import deepcopy @@ -52,12 +53,23 @@ def parse_args(): type=str, default="rep", ) + parser.add_argument( + "--sample-covariates", + help="Comma-separated list of column names in `bdata.samples` that describes non-selective experimental condition. (drug treatment, etc.)", + type=str, + default=None, + ) parser.add_argument( "--condition-label", help="Label of column in `bdata.samples` that describes experimental condition. (sorting bin, time, etc.)", type=str, default="bin", ) + parser.add_argument( + "--no-editing", + help="Ignore QC about editing. Can be used for QC of other editing modalities.", + action="store_true", + ) parser.add_argument( "--target-pos-col", help="Target position column in `bdata.guides` specifying target edit position in reporter", @@ -143,21 +155,30 @@ def check_args(args): ) args.lfc_cond1 = lfc_conds[0] args.lfc_cond2 = lfc_conds[1] + if args.sample_covariates is not None: + if "," in args.sample_covariates: + args.sample_covariates = args.sample_covariates.split(",") + args.replicate_label = [args.replicate_label] + args.sample_covariates + else: + args.replicate_label = [args.replicate_label, args.sample_covariates] + if args.no_editing: + args.base_edit_data = False + else: + args.base_edit_data = True return args -def _add_dummy_sample(bdata, rep, cond, condition_label: str, replicate_label: str): +def _add_dummy_sample( + bdata, rep, cond, condition_label: str, replicate_label: Union[str, List[str]] +): sample_id = f"{rep}_{cond}" cond_df = deepcopy(bdata.samples) - cond_df[replicate_label] = np.nan - cond_df = cond_df.drop_duplicates() + # cond_df = cond_df.drop_duplicates() cond_row = cond_df.loc[cond_df[condition_label] == cond, :] - if not len(cond_row) == 1: - raise ValueError( - f"Non-unique condition specification in ReporterScreen.samples: {cond_row}" - ) + if len(cond_row) != 1: + cond_row = cond_row.iloc[[0], :] cond_row.index = [sample_id] - cond_row.loc[:, replicate_label] = rep + cond_row[replicate_label] = rep dummy_sample_bdata = ReporterScreen( X=np.zeros((bdata.n_obs, 1)), X_bcmatch=np.zeros((bdata.n_obs, 1)), @@ -175,27 +196,40 @@ def _add_dummy_sample(bdata, rep, cond, condition_label: str, replicate_label: s return bdata -def fill_in_missing_samples(bdata, condition_label: str, replicate_label: str): +def fill_in_missing_samples( + bdata, condition_label: str, replicate_label: Union[str, List[str]] +): """If not all condition exists for every replicate in bdata, fill in fake sample""" added_dummy = False - for rep in bdata.samples[replicate_label].unique(): + if isinstance(replicate_label, str): + rep_list = bdata.samples[replicate_label].unique() + else: + rep_list = ( + bdata.samples[replicate_label].drop_duplicates().to_records(index=False) + ) + # print(rep_list) + for rep in rep_list: for cond in bdata.samples[condition_label].unique(): + if isinstance(replicate_label, str): + rep_samples = bdata.samples[replicate_label] == rep + else: + rep = list(rep) + rep_samples = (bdata.samples[replicate_label] == rep).all(axis=1) if ( - len( - np.where( - (bdata.samples[replicate_label] == rep) - & (bdata.samples[condition_label] == cond) - )[0] - ) + len(np.where(rep_samples & (bdata.samples[condition_label] == cond))[0]) != 1 ): + print(f"Adding dummy samples for {rep}, {cond}") bdata = _add_dummy_sample( bdata, rep, cond, condition_label, replicate_label ) if not added_dummy: added_dummy = True if added_dummy: - bdata = bdata[ - :, bdata.samples.sort_values([replicate_label, condition_label]).index - ] + if isinstance(replicate_label, str): + sort_labels = [replicate_label, condition_label] + else: + sort_labels = replicate_label + [condition_label] + bdata = bdata[:, bdata.samples.sort_values(sort_labels).index] + return bdata diff --git a/bin/bean-qc b/bin/bean-qc index c3eb72e..c83d6f2 100644 --- a/bin/bean-qc +++ b/bin/bean-qc @@ -34,6 +34,7 @@ def main(): ctrl_cond=args.ctrl_cond, exp_id=args.out_report_prefix, recalculate_edits=args.recalculate_edits, + base_edit_data=args.base_edit_data, ), kernel_name="bean_python3", ) diff --git a/bin/bean-run b/bin/bean-run index 5e44330..5daca76 100644 --- a/bin/bean-run +++ b/bin/bean-run @@ -3,8 +3,8 @@ import os import sys import logging import warnings -from copy import deepcopy from functools import partial +from copy import deepcopy import numpy as np import pandas as pd @@ -14,26 +14,23 @@ import pyro.infer import pyro.optim import pickle as pkl -from bean.qc.guide_qc import filter_no_info_target import bean.model.model as m from bean.model.readwrite import write_result_table -from bean.preprocessing.data_class import ( - VariantSortingScreenData, - VariantSortingReporterScreenData, - TilingSortingReporterScreenData, -) +from bean.preprocessing.data_class import DATACLASS_DICT from bean.preprocessing.utils import ( + prepare_bdata, _obtain_effective_edit_rate, _obtain_n_guides_alleles_per_variant, _obtain_n_cooccurring_variants, ) import bean as be -from bean.model.utils import ( +from bean.model.run import ( run_inference, _get_guide_target_info, parse_args, check_args, + identify_model_guide, ) logging.basicConfig( @@ -49,21 +46,12 @@ debug = logging.debug info = logging.info pyro.set_rng_seed(101) -DATACLASS_DICT = { - "Normal": VariantSortingScreenData, - "MixtureNormal": VariantSortingReporterScreenData, - "_MixtureNormal+Acc": VariantSortingReporterScreenData, # TODO: old - "MixtureNormal+Acc": VariantSortingReporterScreenData, - "MixtureNormalConstPi": VariantSortingScreenData, - "MultiMixtureNormal": TilingSortingReporterScreenData, - "MultiMixtureNormal+Acc": TilingSortingReporterScreenData, -} - warnings.filterwarnings( "ignore", category=FutureWarning, message=r".*is_categorical_dtype is deprecated and will be removed in a future version.*", ) + warnings.filterwarnings( "ignore", category=FutureWarning, @@ -71,61 +59,17 @@ warnings.filterwarnings( ) -def identify_model_guide(args): - if args.mode == "tiling": - info("Using Mixture Normal model...") - return ( - f"MultiMixtureNormal{'+Acc' if args.scale_by_acc else ''}", - partial( - m.MultiMixtureNormalModel, - scale_by_accessibility=args.scale_by_acc, - use_bcmatch=(not args.ignore_bcmatch,), - ), - partial( - m.MultiMixtureNormalGuide, - scale_by_accessibility=args.scale_by_acc, - fit_noise=~args.dont_fit_noise, - ), - ) - if args.uniform_edit: - if args.guide_activity_col is not None: - raise ValueError( - "Can't use the guide activity column while constraining uniform edit." - ) - info("Using Normal model...") - return ( - "Normal", - partial(m.NormalModel, use_bcmatch=(not args.ignore_bcmatch)), - m.NormalGuide, - ) - elif args.const_pi: - if args.guide_activity_col is not None: - raise ValueError( - "--guide-activity-col to be used as constant pi is not provided." - ) - info("Using Mixture Normal model with constant weight ...") - return ( - "MixtureNormalConstPi", - partial(m.MixtureNormalConstPiModel, use_bcmatch=(not args.ignore_bcmatch)), - m.MixtureNormalGuide, - ) - else: - info( - f"Using Mixture Normal model {'with accessibility normalization' if args.scale_by_acc else ''}..." - ) - return ( - f"{'_' if args.dont_fit_noise else ''}MixtureNormal{'+Acc' if args.scale_by_acc else ''}", - partial( - m.MixtureNormalModel, - scale_by_accessibility=args.scale_by_acc, - use_bcmatch=(not args.ignore_bcmatch,), - ), - partial( - m.MixtureNormalGuide, - scale_by_accessibility=args.scale_by_acc, - fit_noise=(not args.dont_fit_noise), - ), - ) +DATACLASS_DICT = { + "Normal": VariantSortingScreenData, + "MixtureNormal": VariantSortingReporterScreenData, + "_MixtureNormal+Acc": VariantSortingReporterScreenData, # TODO: old + "MixtureNormal+Acc": VariantSortingReporterScreenData, + "MixtureNormalConstPi": VariantSortingScreenData, + "MultiMixtureNormal": TilingSortingReporterScreenData, + "MultiMixtureNormal+Acc": TilingSortingReporterScreenData, +} + + def main(args, bdata): @@ -141,32 +85,10 @@ def main(args, bdata): ) os.makedirs(prefix, exist_ok=True) model_label, model, guide = identify_model_guide(args) - guide_index = bdata.guides.index info("Done loading data. Preprocessing...") - bdata.samples[args.replicate_col] = bdata.samples[args.replicate_col].astype( - "category" - ) - bdata.guides = bdata.guides.loc[:, ~bdata.guides.columns.duplicated()].copy() - if args.mode == "variant": - if bdata.guides[args.target_col].isnull().any(): - raise ValueError( - f"Some target column (bdata.guides[{args.target_col}]) value is null. Check your input file." - ) - bdata = bdata[bdata.guides[args.target_col].argsort(), :] - if args.mode == "variant": - n_no_support_targets, bdata = filter_no_info_target( - bdata, - condit_col=args.condition_col, - control_condition=args.control_condition_label, - target_col=args.target_col, - write_no_support_targets=True, - no_support_target_write_path=f"{prefix}/no_support_targets.csv", - ) - if n_no_support_targets > 0: - warn( - f"Ignoring {n_no_support_targets} targets with 0 gRNA counts across all non-control samples. Ignored targets are written in {prefix}/no_support_targets.csv." - ) - ndata = DATACLASS_DICT[model_label]( + bdata = prepare_bdata(bdata, args, warn, prefix) + guide_index = bdata.guides.index.copy() + ndata = DATACLASS_DICT[args.selection][model_label]( screen=bdata, device=args.device, repguide_mask=args.repguide_mask, @@ -175,21 +97,24 @@ def main(args, bdata): accessibility_bw_path=args.acc_bw_path, use_const_pi=args.const_pi, condition_column=args.condition_col, + time_column=args.time_col, control_condition=args.control_condition_label, + control_can_be_selected=args.include_control_condition_for_inference, allele_df_key=args.allele_df_key, control_guide_tag=args.control_guide_tag, target_col=args.target_col, shrink_alpha=args.shrink_alpha, + popt=args.popt, replicate_col=args.replicate_col, use_bcmatch=(not args.ignore_bcmatch), ) adj_negctrl_idx = None - if args.mode == "variant": + if args.library_design == "variant": if not args.uniform_edit: - if "edit_rate" not in bdata.guides.columns: - bdata.get_edit_from_allele() - bdata.get_edit_mat_from_uns(rel_pos_is_reporter=True) - bdata.get_guide_edit_rate() + if "edit_rate" not in ndata.screen.guides.columns: + ndata.screen.get_edit_from_allele() + ndata.screen.get_edit_mat_from_uns(rel_pos_is_reporter=True) + ndata.screen.get_guide_edit_rate() target_info_df = _get_guide_target_info( ndata.screen, args, cols_include=[args.negctrl_col] ) @@ -226,19 +151,29 @@ def main(args, bdata): with open(f"{prefix}/{model_label}.result.pkl", "rb") as handle: param_history_dict = pkl.load(handle) else: - param_history_dict = deepcopy(run_inference(model, guide, ndata)) + param_history_dict = deepcopy( + run_inference(model, guide, ndata, num_steps=args.n_iter) + ) if args.fit_negctrl: - negctrl_model = m.ControlNormalModel - negctrl_guide = m.ControlNormalGuide + negctrl_model = partial( + m.ControlNormalModel, + use_bcmatch=(not args.ignore_bcmatch and "X_bcmatch" in bdata.layers), + ) + + negctrl_guide = partial( + m.ControlNormalGuide, + use_bcmatch=(not args.ignore_bcmatch and "X_bcmatch" in bdata.layers), + ) negctrl_idx = np.where( guide_info_df[args.negctrl_col].map(lambda s: s.lower()) == args.negctrl_col_value.lower() )[0] - print(len(negctrl_idx)) - print(negctrl_idx.shape) + info( + f"Using {len(negctrl_idx)} negative control elements to adjust phenotypic effect sizes..." + ) ndata_negctrl = ndata[negctrl_idx] param_history_dict["negctrl"] = run_inference( - negctrl_model, negctrl_guide, ndata_negctrl + negctrl_model, negctrl_guide, ndata_negctrl, num_steps=args.n_iter ) outfile_path = ( @@ -266,6 +201,10 @@ def main(args, bdata): else None, adjust_confidence_by_negative_control=args.adjust_confidence_by_negative_control, adjust_confidence_negatives=adj_negctrl_idx, + sd_is_fitted=(args.selection == "sorting"), + sample_covariates=ndata.sample_covariates + if hasattr(ndata, "sample_covariates") + else None, ) info("Done!") diff --git a/notebooks/sample_quality_report.ipynb b/notebooks/sample_quality_report.ipynb index 8d9378b..49d88f1 100644 --- a/notebooks/sample_quality_report.ipynb +++ b/notebooks/sample_quality_report.ipynb @@ -85,6 +85,17 @@ " raise ValueError(\"Ambiguous assignment if the screen is a tiling screen. Provide `--tiling=True` or `tiling=False`.\")" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if not isinstance(replicate_label, str):\n", + " bdata.uns[\"sample_covariates\"] = replicate_label[1:]\n", + "bdata.samples[replicate_label] = bdata.samples[replicate_label].astype(str)" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -215,12 +226,7 @@ "outputs": [], "source": [ "selected_guides = bdata.guides[posctrl_col] == posctrl_val if posctrl_col else ~bdata.guides.index.isnull()\n", - "ax=pt.qc.plot_lfc_correlation(bdata, selected_guides, method=\"Spearman\", cond1=comp_cond1, cond2=comp_cond2, rep_col=replicate_label, compare_col=condition_label, figsize=(10,10))\n", - "\n", - "ax.set_title(\"top/bot LFC correlation, Spearman\")\n", - "plt.yticks(rotation=0) \n", - "plt.xticks(rotation=90) \n", - "plt.show()" + "print(f\"Calculating LFC correlation of {sum(selected_guides)} {'positive control' if posctrl_col else 'all'} guides.\")" ] }, { @@ -228,7 +234,23 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [] + "source": [ + "ax = pt.qc.plot_lfc_correlation(\n", + " bdata,\n", + " selected_guides,\n", + " method=\"Spearman\",\n", + " cond1=comp_cond1,\n", + " cond2=comp_cond2,\n", + " rep_col=replicate_label,\n", + " compare_col=condition_label,\n", + " figsize=(10, 10),\n", + ")\n", + "\n", + "ax.set_title(\"top/bot LFC correlation, Spearman\")\n", + "plt.yticks(rotation=0)\n", + "plt.xticks(rotation=90)\n", + "plt.show()" + ] }, { "cell_type": "code", @@ -250,7 +272,12 @@ "metadata": {}, "outputs": [], "source": [ - "if recalculate_edits or \"edits\" not in bdata.layers.keys() or bdata.layers['edits'].max() == 0:\n", + "if \"target_base_change\" not in bdata.uns or not base_edit_data:\n", + " bdata.uns[\"target_base_change\"] = \"\"\n", + " base_edit_data = False\n", + " print(\"Not a base editing data or target base change not provided. Passing editing-related QC\")\n", + " edit_rate_threshold = -0.1\n", + "elif recalculate_edits or \"edits\" not in bdata.layers.keys() or bdata.layers['edits'].max() == 0:\n", " if 'allele_counts' in bdata.uns.keys():\n", " bdata.uns['allele_counts'] = bdata.uns['allele_counts'].loc[bdata.uns['allele_counts'].allele.map(str) != \"\"]\n", " bdata.get_edit_from_allele()\n", @@ -268,7 +295,11 @@ "metadata": {}, "outputs": [], "source": [ - "if \"edits\" in bdata.layers.keys():\n", + "if \"target_base_change\" not in bdata.uns or not base_edit_data:\n", + " print(\n", + " \"Not a base editing data or target base change not provided. Passing editing-related QC\"\n", + " )\n", + "elif \"edits\" in bdata.layers.keys():\n", " bdata.get_guide_edit_rate(\n", " editable_base_start=edit_quantification_start_pos,\n", " editable_base_end=edit_quantification_end_pos,\n", @@ -283,10 +314,14 @@ "metadata": {}, "outputs": [], "source": [ - "if \"edits\" in bdata.layers.keys():\n", + "if \"target_base_change\" not in bdata.uns or not base_edit_data:\n", + " print(\n", + " \"Not a base editing data or target base change not provided. Passing editing-related QC\"\n", + " )\n", + "elif \"edits\" in bdata.layers.keys():\n", " bdata.get_edit_rate(\n", - " editable_base_start = edit_quantification_start_pos, \n", - " editable_base_end=edit_quantification_end_pos\n", + " editable_base_start=edit_quantification_start_pos,\n", + " editable_base_end=edit_quantification_end_pos,\n", " )\n", " be.qc.plot_sample_edit_rates(bdata)" ] @@ -359,11 +394,24 @@ "metadata": {}, "outputs": [], "source": [ - "bdata.samples['mask'] = 1\n", - "bdata.samples.loc[bdata.samples.median_corr_X < corr_X_thres, 'mask'] = 0\n", + "bdata.samples[\"mask\"] = 1\n", + "bdata.samples.loc[\n", + " bdata.samples.median_corr_X.isnull() | (bdata.samples.median_corr_X < corr_X_thres),\n", + " \"mask\",\n", + "] = 0\n", "if \"median_editing_rate\" in bdata.samples.columns.tolist():\n", - " bdata.samples.loc[bdata.samples.median_editing_rate < edit_rate_thres, 'mask'] = 0\n", - "bdata_filtered = bdata[:, bdata.samples[f\"median_lfc_corr.{comp_cond1}_{comp_cond2}\"] > lfc_thres]" + " bdata.samples.loc[bdata.samples.median_editing_rate < edit_rate_thres, \"mask\"] = 0\n", + "if (\n", + " isinstance(replicate_label, str)\n", + " and len(bdata.samples[replicate_label].unique()) > 1\n", + " or isinstance(replicate_label, list)\n", + " and len(bdata.samples[replicate_label].drop_duplicates()) > 1\n", + "):\n", + " bdata_filtered = bdata[\n", + " :, bdata.samples[f\"median_lfc_corr.{comp_cond1}_{comp_cond2}\"] > lfc_thres\n", + " ]\n", + "else:\n", + " bdata_filtered = bdata" ] }, { @@ -373,12 +421,24 @@ "outputs": [], "source": [ "# leave replicate with more than 1 sorting bin data\n", - "rep_n_samples = bdata_filtered.samples.groupby(replicate_label)['mask'].sum()\n", + "rep_n_samples = bdata_filtered.samples.groupby(replicate_label)[\"mask\"].sum()\n", "print(rep_n_samples)\n", "rep_has_too_small_sample = rep_n_samples.loc[rep_n_samples < 2].index.tolist()\n", "rep_has_too_small_sample\n", - "print(f\"Excluding reps {rep_has_too_small_sample} that has less than 2 samples per replicate.\")\n", - "bdata_filtered = bdata_filtered[:, ~bdata_filtered.samples[replicate_label].isin(rep_has_too_small_sample)]" + "print(\n", + " f\"Excluding reps {rep_has_too_small_sample} that has less than 2 samples per replicate.\"\n", + ")\n", + "if isinstance(replicate_label, str):\n", + " samples_include = ~bdata_filtered.samples[replicate_label].isin(\n", + " rep_has_too_small_sample\n", + " )\n", + "else:\n", + " bdata_filtered.samples[\"_rc\"] = bdata_filtered.samples[\n", + " replicate_label\n", + " ].values.tolist()\n", + " samples_include = ~bdata_filtered.samples[\"_rc\"].isin(rep_has_too_small_sample)\n", + " bdata_filtered.samples.pop(\"_rc\")\n", + "bdata_filtered = bdata_filtered[:, samples_include]" ] }, { diff --git a/setup.py b/setup.py index 4b2157f..f41178c 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ setup( name="crispr-bean", - version="0.2.9", + version="0.3.0", python_requires=">=3.8.0", author="Jayoung Ryu", author_email="jayoung_ryu@g.harvard.edu", @@ -36,7 +36,7 @@ "numpy", "pandas", "scipy", - "perturb-tools>=0.2.8", + "perturb-tools>=0.3.0", "matplotlib", "seaborn>=0.13.0", "tqdm", diff --git a/tests/data/tiling_mini_screen_annotated.h5ad b/tests/data/tiling_mini_screen_annotated.h5ad deleted file mode 100644 index 9eae70fb100ae7fe0694e0815de3b88d2cc67d08..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2131872 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� 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%0A)8&*$q;5gIzlFbLodDyP?jV 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