Download scripts/er_rqmodified.py from Qianhui19/iHARP: direct link, hf CLI and curl.
- Browser
- Download file 17.2 kB
-
https://huggingface.co/Qianhui19/iHARP/resolve/main/scripts/er_rqmodified.py
- Command line
-
hf download hf://Qianhui19/iHARP/scripts/er_rqmodified.py
-
curl -L -o er_rqmodified.py https://huggingface.co/Qianhui19/iHARP/resolve/main/scripts/er_rqmodified.py
17.2 kB
| #!/usr/bin/env python3 | |
| """ER RQmodified calculation logic (self-contained, no external project dependencies). | |
| This module is a pure calculation copy synced from the iHARP project root | |
| `ER_RQmodified.py`, for standalone execution by the skill. It keeps the algorithm | |
| identical to the project core engine: | |
| - single-endpoint score ScoreSingle = 100 * reference_AC50 / chemical_AC50 | |
| - CSraw_agonist / CSraw_antagonist = harmonic mean(valid ScoreSingle) | |
| - ch1/ch2, viability are not scored directly | |
| - viability false positive: endpoint_AC50 >= paired_viability_AC50 / 3 -> excluded | |
| - LUC_VM7 special QC | |
| - concentration conversion ng/L = ng/mL * 1000 | |
| Input columns: CompoundName, Endpoint, AC50, HitCall, Concentration(ng/mL)_adjusted | |
| References: agonist 17beta-Estradiol; antagonist 4-Hydroxytamoxifen | |
| Default PNEC: E2 = 2.0, 4OHT = 30.0 | |
| Note: if the ER algorithm of the project core engine is modified, this file must be | |
| updated in sync. | |
| """ | |
| from __future__ import annotations | |
| import csv | |
| from collections import defaultdict | |
| from pathlib import Path | |
| from typing import Iterable | |
| DEFAULT_PNEC_E2 = 2.0 | |
| DEFAULT_PNEC_4OHT = 30.0 | |
| INPUT_CONCENTRATION_COLUMN = 'Concentration(ng/mL)_adjusted' | |
| OUTPUT_CONCENTRATION_COLUMN = 'Concentration(ng/L)_adjusted' | |
| AGONIST_ENDPOINTS = { | |
| 'OT_ER_ERaERa_0480', | |
| 'OT_ER_ERaERa_1440', | |
| 'OT_ER_ERaERb_0480', | |
| 'OT_ER_ERaERb_1440', | |
| 'OT_ER_ERbERb_0480', | |
| 'OT_ER_ERbERb_1440', | |
| 'OT_ERa_EREGFP_0120', | |
| 'OT_ERa_EREGFP_0480', | |
| 'ATG_chERa_XSP1_up', | |
| 'ATG_chERa_XSP2_up', | |
| 'ATG_ERa_TRANS_up', | |
| 'ATG_ERb_TRANS2_up', | |
| 'ATG_frER1_XSP1_up', | |
| 'ATG_frER1_XSP2_up', | |
| 'ATG_frER2_XSP1_up', | |
| 'ATG_frER2_XSP2_up', | |
| 'ATG_hERa_XSP1_up', | |
| 'ATG_hERa_XSP2_up', | |
| 'ATG_hERb_XSP1_up', | |
| 'ATG_hERb_XSP2_up', | |
| 'TOX21_ERa_BLA_Agonist_ratio', | |
| 'TOX21_ERa_LUC_VM7_Agonist', | |
| 'TOX21_ERa_LUC_VM7_Agonist_10nM_ICI182780', | |
| 'TOX21_ERb_BLA_Agonist_ratio', | |
| 'ACEA_ER_80hr', | |
| } | |
| ANTAGONIST_ENDPOINTS = { | |
| 'TOX21_ERa_BLA_Antagonist_ratio', | |
| 'TOX21_ERa_LUC_VM7_Antagonist_0.5nM_E2', | |
| 'TOX21_ERb_BLA_Antagonist_ratio', | |
| } | |
| # ER rule: ch1/ch2 and viability endpoints only participate in QC or retain details; they are not scored directly. | |
| NON_SCORING_ENDPOINTS = { | |
| 'TOX21_ERa_BLA_Agonist_ch1', | |
| 'TOX21_ERa_BLA_Agonist_ch2', | |
| 'TOX21_ERa_BLA_Antagonist_ch1', | |
| 'TOX21_ERa_BLA_Antagonist_ch2', | |
| 'TOX21_ERa_BLA_Antagonist_viability', | |
| 'TOX21_ERa_LUC_VM7_Agonist_10nM_ICI182780_viability', | |
| 'TOX21_ERa_LUC_VM7_Antagonist_0.1nM_E2_viability', | |
| 'TOX21_ERa_LUC_VM7_Antagonist_0.5nM_E2_viability', | |
| 'TOX21_ERb_BLA_Agonist_ch1', | |
| 'TOX21_ERb_BLA_Agonist_ch2', | |
| 'TOX21_ERb_BLA_Agonist_viability', | |
| 'TOX21_ERb_BLA_Antagonist_ch1', | |
| 'TOX21_ERb_BLA_Antagonist_ch2', | |
| 'TOX21_ERb_BLA_Antagonist_viability', | |
| } | |
| FALSE_POSITIVE_QC_PAIRS = { | |
| 'TOX21_ERa_BLA_Antagonist_ratio': 'TOX21_ERa_BLA_Antagonist_viability', | |
| 'TOX21_ERb_BLA_Agonist_ratio': 'TOX21_ERb_BLA_Agonist_viability', | |
| 'TOX21_ERb_BLA_Antagonist_ratio': 'TOX21_ERb_BLA_Antagonist_viability', | |
| 'TOX21_ERa_LUC_VM7_Agonist_10nM_ICI182780': 'TOX21_ERa_LUC_VM7_Agonist_10nM_ICI182780_viability', | |
| 'TOX21_ERa_LUC_VM7_Antagonist_0.5nM_E2': 'TOX21_ERa_LUC_VM7_Antagonist_0.5nM_E2_viability', | |
| } | |
| LUC_VM7_AGONIST_ENDPOINT = 'TOX21_ERa_LUC_VM7_Agonist' | |
| LUC_VM7_0_1NM_QC_ENDPOINT = 'TOX21_ERa_LUC_VM7_Antagonist_0.1nM_E2' | |
| LUC_VM7_0_1NM_QC_VIABILITY_ENDPOINT = 'TOX21_ERa_LUC_VM7_Antagonist_0.1nM_E2_viability' | |
| ENDPOINT_GROUPS = {endpoint: 'agonist' for endpoint in AGONIST_ENDPOINTS} | |
| ENDPOINT_GROUPS.update({endpoint: 'antagonist' for endpoint in ANTAGONIST_ENDPOINTS}) | |
| REFERENCE_BY_GROUP = { | |
| 'agonist': '17beta-Estradiol', | |
| 'antagonist': '4-Hydroxytamoxifen', | |
| } | |
| # Scoring endpoints with no reference-compound AC50 data: the reference compound | |
| # 17beta-Estradiol was not assayed for these endpoints in ToxCast, so | |
| # ScoreSingle (=100*ref_AC50/chem_AC50) can never be computed - a confirmed data gap. | |
| # These endpoints are silently skipped (no Missing reference AC50 warning) to avoid | |
| # uninformative warning spam. | |
| KNOWN_MISSING_REFERENCE_ENDPOINTS = { | |
| 'ATG_chERa_XSP1_up', | |
| 'ATG_chERa_XSP2_up', | |
| 'ATG_frER1_XSP1_up', | |
| 'ATG_frER1_XSP2_up', | |
| 'ATG_frER2_XSP1_up', | |
| 'ATG_frER2_XSP2_up', | |
| 'ATG_hERa_XSP1_up', | |
| 'ATG_hERa_XSP2_up', | |
| 'ATG_hERb_XSP1_up', | |
| 'ATG_hERb_XSP2_up', | |
| 'ATG_ERb_TRANS2_up', | |
| } | |
| DETAIL_COLUMNS = [ | |
| 'CompoundName', | |
| OUTPUT_CONCENTRATION_COLUMN, | |
| 'CSraw_agonist', | |
| 'CSraw_antagonist', | |
| 'EC_agonist', | |
| 'EC_antagonist', | |
| 'RQmodified_ag', | |
| 'RQmodified_ant', | |
| ] | |
| SUMMARY_COLUMNS = ['SUM_RQmodified_ag', 'SUM_RQmodified_ant'] | |
| def read_csv(path: str | Path) -> list[dict[str, str]]: | |
| with Path(path).open(newline='', encoding='utf-8-sig') as f: | |
| return list(csv.DictReader(f)) | |
| def write_csv(path: str | Path, rows: Iterable[dict[str, object]], fieldnames: list[str]) -> None: | |
| with Path(path).open('w', newline='', encoding='utf-8') as f: | |
| writer = csv.DictWriter(f, fieldnames=fieldnames) | |
| writer.writeheader() | |
| for row in rows: | |
| writer.writerow(row) | |
| def parse_positive_float(value: object) -> float | None: | |
| if value is None: | |
| return None | |
| text = str(value).strip() | |
| if not text: | |
| return None | |
| try: | |
| parsed = float(text) | |
| except ValueError: | |
| return None | |
| if parsed <= 0: | |
| return None | |
| return parsed | |
| def convert_ng_ml_to_ng_l(value: object) -> float | None: | |
| concentration_ng_ml = parse_positive_float(value) | |
| if concentration_ng_ml is None: | |
| return None | |
| # project convention: ng/L = ng/mL * 1000 | |
| return concentration_ng_ml * 1000 | |
| def normalize_hitcall(value: object) -> str: | |
| return str(value or '').strip().lower() | |
| def harmonic_mean(values: Iterable[float]) -> float: | |
| positives = [value for value in values if value > 0] | |
| if not positives: | |
| return 0.0 | |
| return len(positives) / sum(1 / value for value in positives) | |
| def build_reference_map(reference_rows: Iterable[dict[str, str]]) -> dict[tuple[str, str], float]: | |
| reference: dict[tuple[str, str], float] = {} | |
| scoring_endpoints = set(ENDPOINT_GROUPS) | |
| for row in reference_rows: | |
| if normalize_hitcall(row.get('HitCall')) != 'active': | |
| continue | |
| compound = str(row.get('CompoundName', '')).strip() | |
| endpoint = str(row.get('Endpoint', '')).strip() | |
| if endpoint not in scoring_endpoints: | |
| continue | |
| ac50 = parse_positive_float(row.get('AC50')) | |
| if compound and endpoint and ac50 is not None: | |
| reference.setdefault((compound, endpoint), ac50) | |
| return reference | |
| def build_endpoint_ac50_map(input_rows: Iterable[dict[str, str]]) -> dict[tuple[str, str, str], float]: | |
| endpoint_ac50: dict[tuple[str, str, str], float] = {} | |
| for row in input_rows: | |
| compound = str(row.get('CompoundName', '')).strip() | |
| endpoint = str(row.get('Endpoint', '')).strip() | |
| concentration_ng_l = convert_ng_ml_to_ng_l(row.get(INPUT_CONCENTRATION_COLUMN)) | |
| concentration = '' if concentration_ng_l is None else format_number(concentration_ng_l) | |
| ac50 = parse_positive_float(row.get('AC50')) | |
| if compound and endpoint and ac50 is not None: | |
| endpoint_ac50.setdefault((compound, concentration, endpoint), ac50) | |
| return endpoint_ac50 | |
| def is_false_positive_endpoint( | |
| compound: str, | |
| concentration: str, | |
| endpoint: str, | |
| ac50_chem: float, | |
| endpoint_ac50: dict[tuple[str, str, str], float], | |
| ) -> tuple[bool, float | None]: | |
| viability_endpoint = FALSE_POSITIVE_QC_PAIRS.get(endpoint) | |
| if viability_endpoint is None: | |
| return False, None | |
| viability_ac50 = endpoint_ac50.get((compound, concentration, viability_endpoint)) | |
| if viability_ac50 is None: | |
| return False, None | |
| return ac50_chem >= viability_ac50 / 3, viability_ac50 | |
| def should_skip_luc_vm7_agonist( | |
| compound: str, | |
| concentration: str, | |
| agonist_ac50: float, | |
| endpoint_ac50: dict[tuple[str, str, str], float], | |
| ) -> tuple[bool, str | None]: | |
| qc_ac50 = endpoint_ac50.get((compound, concentration, LUC_VM7_0_1NM_QC_ENDPOINT)) | |
| if qc_ac50 is None: | |
| return False, None | |
| viability_ac50 = endpoint_ac50.get((compound, concentration, LUC_VM7_0_1NM_QC_VIABILITY_ENDPOINT)) | |
| if viability_ac50 is not None and qc_ac50 >= viability_ac50 / 3: | |
| return False, ( | |
| 'QC endpoint false positive; agonist retained: ' | |
| f'CompoundName={compound}; {OUTPUT_CONCENTRATION_COLUMN}={concentration}; ' | |
| f'AgonistEndpoint={LUC_VM7_AGONIST_ENDPOINT}; ' | |
| f'QCEndpoint={LUC_VM7_0_1NM_QC_ENDPOINT}; QCAC50={format_number(qc_ac50)}; ' | |
| f'ViabilityEndpoint={LUC_VM7_0_1NM_QC_VIABILITY_ENDPOINT}; ' | |
| f'ViabilityAC50={format_number(viability_ac50)}; Rule=QC AC50 >= viability AC50 / 3' | |
| ) | |
| if qc_ac50 >= 10 * agonist_ac50: | |
| return True, ( | |
| 'False positive agonist endpoint skipped: ' | |
| f'CompoundName={compound}; {OUTPUT_CONCENTRATION_COLUMN}={concentration}; ' | |
| f'Endpoint={LUC_VM7_AGONIST_ENDPOINT}; AC50={format_number(agonist_ac50)}; ' | |
| f'QCEndpoint={LUC_VM7_0_1NM_QC_ENDPOINT}; QCAC50={format_number(qc_ac50)}; ' | |
| 'Rule=0.1nM_E2 AC50 >= 10 * agonist AC50' | |
| ) | |
| return False, None | |
| def format_number(value: float) -> str: | |
| if abs(value) < 1e-15: | |
| value = 0.0 | |
| return f'{value:.12g}' | |
| def max_rqmodified(rqmodified_ag: object, rqmodified_ant: object) -> float | None: | |
| values = [] | |
| for value in (rqmodified_ag, rqmodified_ant): | |
| try: | |
| if value is not None: | |
| values.append(float(value)) | |
| except (TypeError, ValueError): | |
| pass | |
| if not values: | |
| return None | |
| return max(values) | |
| def calculate_rqmodified( | |
| input_rows: Iterable[dict[str, str]], | |
| reference_rows: Iterable[dict[str, str]], | |
| pnec_e2: float = DEFAULT_PNEC_E2, | |
| pnec_4oht: float = DEFAULT_PNEC_4OHT, | |
| ) -> tuple[list[dict[str, object]], list[str], dict[str, float]]: | |
| input_rows = list(input_rows) | |
| reference = build_reference_map(reference_rows) | |
| endpoint_ac50 = build_endpoint_ac50_map(input_rows) | |
| warnings: list[str] = [] | |
| grouped_scores: dict[tuple[str, str], dict[str, list[float]]] = defaultdict( | |
| lambda: {'agonist': [], 'antagonist': []} | |
| ) | |
| seen_compound_concentrations: set[tuple[str, str]] = set() | |
| concentrations_by_compound: dict[str, set[str]] = defaultdict(set) | |
| missing_reference_seen: set[tuple[str, str, str]] = set() | |
| for row in input_rows: | |
| compound = str(row.get('CompoundName', '')).strip() | |
| concentration_ng_l = convert_ng_ml_to_ng_l(row.get(INPUT_CONCENTRATION_COLUMN)) | |
| concentration = '' if concentration_ng_l is None else format_number(concentration_ng_l) | |
| if not compound: | |
| continue | |
| seen_compound_concentrations.add((compound, concentration)) | |
| concentrations_by_compound[compound].add(concentration) | |
| for compound in sorted(concentrations_by_compound): | |
| concentrations = sorted(concentrations_by_compound[compound]) | |
| if len(concentrations) > 1: | |
| warnings.append( | |
| 'Concentration not unique: ' | |
| f'CompoundName={compound}; {OUTPUT_CONCENTRATION_COLUMN}={"|".join(concentrations)}' | |
| ) | |
| for row in input_rows: | |
| if normalize_hitcall(row.get('HitCall')) != 'active': | |
| continue | |
| compound = str(row.get('CompoundName', '')).strip() | |
| concentration_ng_l = convert_ng_ml_to_ng_l(row.get(INPUT_CONCENTRATION_COLUMN)) | |
| concentration = '' if concentration_ng_l is None else format_number(concentration_ng_l) | |
| endpoint = str(row.get('Endpoint', '')).strip() | |
| if not compound: | |
| continue | |
| if endpoint in NON_SCORING_ENDPOINTS: | |
| continue | |
| group = ENDPOINT_GROUPS.get(endpoint) | |
| if group is None: | |
| continue | |
| ac50_chem = parse_positive_float(row.get('AC50')) | |
| if ac50_chem is None: | |
| warnings.append( | |
| f'Invalid AC50 skipped: CompoundName={compound}; ' | |
| f'{INPUT_CONCENTRATION_COLUMN}={row.get(INPUT_CONCENTRATION_COLUMN)}; Endpoint={endpoint}; AC50={row.get("AC50")}' | |
| ) | |
| continue | |
| if endpoint == LUC_VM7_AGONIST_ENDPOINT: | |
| skip_agonist, qc_warning = should_skip_luc_vm7_agonist( | |
| compound, | |
| concentration, | |
| ac50_chem, | |
| endpoint_ac50, | |
| ) | |
| if qc_warning is not None: | |
| warnings.append(qc_warning) | |
| if skip_agonist: | |
| continue | |
| is_false_positive, viability_ac50 = is_false_positive_endpoint( | |
| compound, | |
| concentration, | |
| endpoint, | |
| ac50_chem, | |
| endpoint_ac50, | |
| ) | |
| if is_false_positive: | |
| warnings.append( | |
| 'False positive endpoint skipped: ' | |
| f'CompoundName={compound}; {OUTPUT_CONCENTRATION_COLUMN}={concentration}; ' | |
| f'Endpoint={endpoint}; AC50={format_number(ac50_chem)}; ' | |
| f'ViabilityEndpoint={FALSE_POSITIVE_QC_PAIRS[endpoint]}; ' | |
| f'ViabilityAC50={format_number(viability_ac50)}; Rule=endpoint AC50 >= viability AC50 / 3' | |
| ) | |
| continue | |
| reference_compound = REFERENCE_BY_GROUP[group] | |
| reference_ac50 = reference.get((reference_compound, endpoint)) | |
| if reference_ac50 is None: | |
| if endpoint in KNOWN_MISSING_REFERENCE_ENDPOINTS: | |
| continue # known no-reference-data endpoint, skip silently without warning | |
| key = (endpoint, group, reference_compound) | |
| if key not in missing_reference_seen: | |
| warnings.append( | |
| 'Missing reference AC50: ' | |
| f'Endpoint={endpoint}; Group={group}; Reference={reference_compound}' | |
| ) | |
| missing_reference_seen.add(key) | |
| continue | |
| score_single = 100 * reference_ac50 / ac50_chem | |
| if score_single > 0: | |
| grouped_scores[(compound, concentration)][group].append(score_single) | |
| for key in seen_compound_concentrations: | |
| grouped_scores.setdefault(key, {'agonist': [], 'antagonist': []}) | |
| results: list[dict[str, object]] = [] | |
| for compound, concentration in sorted(grouped_scores, key=lambda item: (item[0], item[1])): | |
| scores = grouped_scores[(compound, concentration)] | |
| csraw_agonist = harmonic_mean(scores['agonist']) | |
| csraw_antagonist = harmonic_mean(scores['antagonist']) | |
| concentration_value = parse_positive_float(concentration) | |
| ec_agonist: float | None = None | |
| ec_antagonist: float | None = None | |
| rqmodified_ag: float | None = None | |
| rqmodified_ant: float | None = None | |
| if concentration_value is not None: | |
| ec_agonist = concentration_value * (csraw_agonist / 100) | |
| ec_antagonist = concentration_value * (csraw_antagonist / 100) | |
| if pnec_e2 > 0 and csraw_agonist > 0: | |
| rqmodified_ag = ec_agonist / pnec_e2 | |
| if pnec_4oht > 0 and csraw_antagonist > 0: | |
| rqmodified_ant = ec_antagonist / pnec_4oht | |
| results.append( | |
| { | |
| 'CompoundName': compound, | |
| OUTPUT_CONCENTRATION_COLUMN: concentration, | |
| 'CSraw_agonist': csraw_agonist, | |
| 'CSraw_antagonist': csraw_antagonist, | |
| 'EC_agonist': ec_agonist if csraw_agonist > 0 else None, | |
| 'EC_antagonist': ec_antagonist if csraw_antagonist > 0 else None, | |
| 'RQmodified_ag': rqmodified_ag, | |
| 'RQmodified_ant': rqmodified_ant, | |
| } | |
| ) | |
| totals = { | |
| 'SUM_RQmodified_ag': sum(float(row['RQmodified_ag']) for row in results if row['RQmodified_ag'] is not None), | |
| 'SUM_RQmodified_ant': sum(float(row['RQmodified_ant']) for row in results if row['RQmodified_ant'] is not None), | |
| } | |
| return results, warnings, totals | |
| def format_detail_rows(rows: Iterable[dict[str, object]]) -> list[dict[str, str]]: | |
| formatted_rows: list[dict[str, str]] = [] | |
| for row in rows: | |
| formatted_rows.append( | |
| { | |
| 'CompoundName': str(row['CompoundName']), | |
| OUTPUT_CONCENTRATION_COLUMN: str(row[OUTPUT_CONCENTRATION_COLUMN]), | |
| 'CSraw_agonist': format_number(float(row['CSraw_agonist'])), | |
| 'CSraw_antagonist': format_number(float(row['CSraw_antagonist'])), | |
| 'EC_agonist': '' if row['EC_agonist'] is None else format_number(float(row['EC_agonist'])), | |
| 'EC_antagonist': '' if row['EC_antagonist'] is None else format_number(float(row['EC_antagonist'])), | |
| 'RQmodified_ag': '' if row['RQmodified_ag'] is None else format_number(float(row['RQmodified_ag'])), | |
| 'RQmodified_ant': '' if row['RQmodified_ant'] is None else format_number(float(row['RQmodified_ant'])), | |
| } | |
| ) | |
| return formatted_rows | |