
Часть II: Скоринг, off-target и работа с реальными данными (вы тута)
В первой части мы разобрались с базовой биологией CRISPR-Cas9 и написали CLI-утилиту для поиска gRNA. Фильтровали по GC-составу и наличию повторов. В итоге получился рабочий, но очень простой инструмент.
Сегодня добавим функций: научим программу сортировать кандидатов по качеству и проверять, не порежет ли наша gRNA что-нибудь лишнее в геноме. А чтобы было интереснее прогоним всё на настоящем гене с криминальной историей.
Почему CCR5?
Помните скандал 2018 года? Китайский учёный Хэ Цзянькуй отредактировал геном человеческих эмбрионов и довёл беременность до родов. На свет появились близнецы Лулу и Нана - первые генетически модифицированные люди. Учёный получил три года тюрьмы, а научное сообщество - этическую мигрень на десятилетие вперёд.
Хэ целился в ген CCR5. Этот ген кодирует белок-рецептор на поверхности иммунных клеток - это что-то вроде дверной ручки, за которую ВИЧ хватается, чтобы проникнуть внутрь. Примерно у 1% европейцев есть мутация CCR5-Δ32: у них эта "ручка" сломана и, как следствие, такие люди устойчивы к ВИЧ от рождения.
Идея Хэ была в том, чтобы искусственно "сломать" CCR5 у эмбрионов и сделать детей невосприимчивыми к вирусу. Этика эксперимента - это отдельный разговор, но ген CCR5 с тех пор стал, пожалуй, самой известной мишенью для CRISPR.
Его и возьмём для тестов.
Ликбез: 5' и 3' - что за штрихи?
Прежде чем лезть в код, разберёмся с одной штукой, которая постоянно мелькает в биоинформатике.
ДНК - это цепочка нуклеотидов (A, C, T, G), связанных через сахарофосфатный "скелет". Каждый сахар в этом скелете - молекула дезоксирибозы с пронумерованными атомами углерода: 1', 2', 3', 4', 5' (читается "один штрих", "пять штрих"). Штрих - чтобы не путать с номерами атомов в азотистых основаниях.
Цепь ДНК всегда имеет направление, как улица с односторонним движением:
5'-конец - начало цепи, там торчит свободная фосфатная группа
3'-конец - конец цепи, там свободная OH-группа
Представьте цепочку вагонов: 5'-конец - это локомотив, 3'-конец - последний вагон. Все ферменты "читают" ДНК в одном направлении (от 5' к 3'), как поезд едет от головы к хвосту.
Две цепи ДНК идут "навстречу" друг другу:
5'-ATGCCTGA-3' → (прямая цепь)
||||||||
3'-TACGGACT-5' ← (комплементарная)
Когда мы говорим "3'-конец gRNA", имеем в виду ту часть, которая ближе к PAM. Это важно для скоринга - скоро увидите почему.
Скоринг: не все gRNA одинаково полезны
В первой части мы отбирали gRNA по двум критериям: GC от 40 до 60% и отсутствие повторов. Но внутри этого диапазона кандидаты сильно различаются по эффективности.
Представьте, что вы выбираете сотрудника. Формально все кандидаты подходят (есть диплом, опыт работы), но кто-то явно лучше, поэтому нужна система оценки.
Что влияет на качество gRNA
GC-состав около 50% - золотая середина. Слишком много GC - цепи слипаются слишком крепко, gRNA не хочет отпускать ДНК после разрезания. Слишком мало - связывание слабое, Cas9 может "соскочить" раньше времени.
Нуклеотиды на концах - 3'-конец gRNA (ближе к PAM) критичен для связывания. Если там много G и C, комплекс получается слишком стабильным - как клей, который схватился намертво. Cas9 режет, но не может отцепиться и пойти дальше.
Первый нуклеотид - gRNA обычно экспрессируют с U6-промотора, а он капризный. Если первая буква - T (в РНК это U), промотор работает плохо. Если G - отлично.
Формула скоринга
Начинаем со 100 очков и вычитаем штрафы:
def calculate_score(sequence: str, gc_content: float, has_repeats: bool) -> float:
"""
Оценивает качество gRNA.
100 = идеал, штрафы уменьшают скор.
Бонус за G в начале может дать >100.
"""
score = 100.0
# Штраф за отклонение от оптимального GC (50%)
# Каждый процент отклонения = -1.5 очка
score -= abs(gc_content - 50) * 1.5
# повторы - это плохо
if has_repeats:
score -= 20
# Проверяем 3'-конец (последние 6 нуклеотидов)
last_6 = sequence[-6:]
gc_at_end = last_6.count('G') + last_6.count('C')
if gc_at_end > 3:
score -= (gc_at_end - 3) * 5
# Первый нуклеотид
if sequence[0] == 'T':
score -= 10 # U6 не любит тимин
elif sequence[0] == 'G':
score += 5 # U6 любит гуанин
return max(0, score)
Это упрощённая модель. Настоящие алгоритмы вроде Rule Set 2 используют машинное обучение на тысячах экспериментов, учитывают позицию каждого нуклеотида и ещё кучу факторов. Но для понимания принципа - достаточно.
Off-target: ищем случайных жертв
Самая большая проблема CRISPR специфичность. Геном человека - это три миллиарда пар нуклеотидов. Наша gRNA составляет всего 20 букв. Какова вероятность, что такая последовательность встретится где-то ещё?
Спойлер: высокая. И даже если точного совпадения нет, Cas9 может разрезать участки с 1-2 отличиями. Представьте, что вы ищете в толпе человека по описанию "высокий брюнет в синей куртке". Найдёте нужного, но по дороге обратите внимание на пару похожих.
Для поиска таких "похожих" используют BLAST.
Что такое BLAST
BLAST (Basic Local Alignment Search Tool) - это Google для биологов. Вы даёте ему последовательность, он ищет похожие в гигантских базах данных: геномах, транскриптомах, протеомах.
Алгоритм хитрый: вместо того чтобы сравнивать каждую букву (это заняло бы вечность), BLAST сначала ищет короткие "слова" такие совпадения из 7-11 букв. Нашёл слово и расширяет в обе стороны, проверяя, насколько длинное совпадение получится.
В ответ отдаёт список отобранных и метрики:
E-value - вероятность случайного совпадения. E-value = 0.001 значит "такое совпадение случайно встретится раз на 1000 запросов". Чем меньше - тем надёжнее.
Identity - процент идентичных букв. 100% = точное совпадение.
Coverage - какая часть запроса покрыта совпадением.
Biopython: швейцарский нож биоинформатика
Biopython - библиотека, которая умеет почти всё: читать форматы последовательностей, работать с базами NCBI, запускать BLAST, строить филогенетические деревья и многое другое.
Нам нужны два модуля:
Bio.Blast.NCBIWWW- отправляет запросы на сервер NCBIBio.Blast.NCBIXML- парсит результаты
pip install biopython
Проверка off-target через BLAST
from Bio.Blast import NCBIWWW, NCBIXML
def check_off_targets(
sequence: str,
organism: str = "Homo sapiens",
max_hits: int = 50,
) -> list[dict]:
"""
Ищет потенциальные off-target через NCBI BLAST.
Внимание: запрос занимает 30-60 секунд!
NCBI просит не частить - максимум 1 запрос в 10 секунд.
"""
# Добавляем PAM к запросу
query = sequence + "NGG"
result_handle = NCBIWWW.qblast(
program="blastn", # нуклеотидный поиск
database="nt", # nucleotide collection
sequence=query,
entrez_query=f'"{organism}"[organism]',
hitlist_size=max_hits,
word_size=7, # для коротких последовательностей
expect=1000, # высокий порог, чтобы поймать слабые совпадения
megablast=False
)
off_targets = []
for record in NCBIXML.parse(result_handle):
for alignment in record.alignments:
for hsp in alignment.hsps:
identity = (hsp.identities / hsp.align_length) * 100
off_targets.append({
'title': alignment.title[:80],
'identity': round(identity, 1),
'mismatches': hsp.align_length - hsp.identities,
'e_value': hsp.expect,
})
result_handle.close()
return off_targets
Пара замечаний:
word_size=7 - обычно BLAST ищет "слова" из 11 букв, но наш запрос всего 23 буквы. С большим word_size он может ничего не найти.
expect=1000 - нарочно высокий порог. Для off-target нас интересуют даже слабые совпадения: участок с 85% идентичности всё ещё может быть разрезан Cas9.
Оценка риска
Что считать опасным?
≥95% идентичности - Cas9 скорее всего разрежет
85-95% - может разрезать, зависит от позиции мисматчей
<85% - обычно безопасно
def assess_off_target_risk(
off_targets: list[dict],
target_gene: str
) -> dict:
"""Сортирует off-target по уровню риска."""
# Паттерны для фильтрации целевого гена
short_name = target_gene.split()[0].split('_')[0].lower()
target_patterns = [
short_name,
"chemokine receptor 5",
"cc chemokine receptor",
"c-c motif chemokine receptor",
"ccr-5",
"nonfunctional cc chemokine",
"cc chemokine re",
]
high_risk = []
medium_risk = []
low_risk = []
for ot in off_targets:
title_lower = ot['title'].lower()
# Пропускаем сам целевой ген - это не off-target
if any(p in title_lower for p in target_patterns):
continue
if ot['identity'] >= 95:
high_risk.append(ot)
elif ot['identity'] >= 85:
medium_risk.append(ot)
else:
low_risk.append(ot)
return {
'high_risk': high_risk,
'medium_risk': medium_risk,
'low_risk': low_risk,
'is_safe': len(high_risk) == 0,
}
Да-да, знаю, что target_patterns убивает переиспользование для других генов, не CCR5, но для примера никакого простого способа собрать все синонимы, по имени из FASTA файла, я не нашёл :(
Собираем всё вместе
Код второй части живёт в отдельном файле и импортирует функции из первой:
# crispr_finder_v2.py
from pathlib import Path
from Bio.Blast import NCBIWWW, NCBIXML
import typer
from crispr_finder import (
parse_fasta,
analyze_sequence,
filter_candidates,
)
app = typer.Typer()
def calculate_score(sequence: str, gc_content: float, has_repeats: bool) -> float:
"""Оценивает качество gRNA по шкале 0-100+."""
score = 100.0
score -= abs(gc_content - 50) * 1.5
if has_repeats:
score -= 20
last_6 = sequence[-6:]
gc_at_end = last_6.count('G') + last_6.count('C')
if gc_at_end > 3:
score -= (gc_at_end - 3) * 5
if sequence[0] == 'T':
score -= 10
elif sequence[0] == 'G':
score += 5
return max(0, score)
def score_candidates(candidates: list[dict]) -> list[dict]:
"""Добавляет скор к каждому кандидату."""
for c in candidates:
c['score'] = calculate_score(
c['sequence'],
c['gc_content'],
c['has_poly_repeats']
)
return candidates
def check_off_targets(
sequence: str,
organism: str = "Homo sapiens",
max_hits: int = 50,
) -> list[dict]:
"""Ищет off-target через NCBI BLAST."""
query = sequence + "NGG"
result_handle = NCBIWWW.qblast(
program="blastn",
database="nt",
sequence=query,
entrez_query=f'"{organism}"[organism]',
hitlist_size=max_hits,
word_size=7,
expect=1000,
megablast=False
)
off_targets = []
for record in NCBIXML.parse(result_handle):
for alignment in record.alignments:
for hsp in alignment.hsps:
identity = (hsp.identities / hsp.align_length) * 100
off_targets.append({
'title': alignment.title[:80],
'identity': round(identity, 1),
'mismatches': hsp.align_length - hsp.identities,
'e_value': hsp.expect,
})
result_handle.close()
return off_targets
def assess_off_target_risk(off_targets: list[dict], target_gene: str) -> dict:
"""Оценивает риск off-target эффектов."""
short_name = target_gene.split()[0].split('_')[0].lower()
target_patterns = [
short_name,
"chemokine receptor 5",
"c-c motif chemokine receptor 5",
]
high_risk = []
medium_risk = []
low_risk = []
for ot in off_targets:
title_lower = ot['title'].lower()
if any(p in title_lower for p in target_patterns):
continue
if ot['identity'] >= 95:
high_risk.append(ot)
elif ot['identity'] >= 85:
medium_risk.append(ot)
else:
low_risk.append(ot)
return {
'high_risk': high_risk,
'medium_risk': medium_risk,
'low_risk': low_risk,
'is_safe': len(high_risk) == 0,
}
@app.command()
def analyze(
fasta_path: Path = typer.Argument(..., help="Путь к FASTA файлу"),
gc_min: float = typer.Option(40.0, help="Минимальный GC-состав"),
gc_max: float = typer.Option(60.0, help="Максимальный GC-состав"),
top_n: int = typer.Option(10, help="Показать топ N кандидатов"),
check_offtarget: bool = typer.Option(False, help="Проверить off-target (медленно!)"),
):
"""Анализ gRNA с скорингом и опциональной проверкой off-target."""
sequences = parse_fasta(fasta_path)
for name, sequence in sequences.items():
typer.echo(f"\n{'=' * 60}")
typer.echo(f"Ген: {name}")
typer.echo(f"Длина: {len(sequence)} нуклеотидов")
typer.echo(f"{'=' * 60}")
# Ищем и фильтруем кандидатов (функции из первой части)
candidates = analyze_sequence(sequence)
filtered = filter_candidates(candidates, gc_min, gc_max, allow_repeats=False)
# Добавляем скоринг
scored = score_candidates(filtered)
scored.sort(key=lambda x: x['score'], reverse=True)
if not scored:
typer.echo("Кандидатов не найдено")
continue
typer.echo(f"\nНайдено кандидатов: {len(scored)}")
typer.echo(f"Топ-{min(top_n, len(scored))} по скору:\n")
for i, c in enumerate(scored[:top_n], 1):
strand = "+" if c['is_strand'] else "-"
typer.echo(
f"{i:2}. {c['sequence']} | "
f"score: {c['score']:.1f} | "
f"GC: {c['gc_content']:.1f}% | "
f"strand: {strand}"
)
if check_offtarget:
typer.echo(" Проверяю off-target...")
try:
off_targets = check_off_targets(c['sequence'])
risk = assess_off_target_risk(off_targets, name)
if risk['is_safe']:
typer.echo(" ✓ Высокий риск off-target не обнаружен")
else:
typer.echo(
f" ⚠ Риск: {len(risk['high_risk'])} высокий, "
f"{len(risk['medium_risk'])} средний"
)
for ot in risk['high_risk'][:3]:
typer.echo(f" - {ot['title']}: {ot['identity']}%")
except Exception as e:
typer.echo(f" ✗ Ошибка: {e}")
if __name__ == "__main__":
app()
Тестируем на CCR5
Скачиваем последовательность CCR5 (или берём из репозитория) и запускаем:
python crispr_finder_v2.py analyze ccr5.fasta --top-n 5
============================================================
Ген: CCR5_CDS Homo sapiens C-C motif chemokine receptor 5 (CCR5), mRNA
Длина: 1059 нуклеотидов
============================================================
Найдено кандидатов: 62
Топ-5 по скору:
1. GTCATGGTCATCTGCTACTC | score: 105.0 | GC: 50.0% | strand: +
2. GACAAGTGTGATCACTTGGG | score: 100.0 | GC: 50.0% | strand: +
3. ATGCAGGTGACAGAGACTCT | score: 100.0 | GC: 50.0% | strand: +
4. CAGTTTACACCCGATCCACT | score: 100.0 | GC: 50.0% | strand: +
5. GTAAACTGAGCTTGCTCGCT | score: 100.0 | GC: 50.0% | strand: -
Первый кандидат набрал 105 очков - больше "максимума". Это бонус за гуанин в начале: такие gRNA отлично экспрессируются с U6-промотора.
С флагом --check-offtarget программа проверит каждого кандидата через BLAST. Это займёт несколько минут (NCBI не торопится), но покажет, есть ли в геноме человека опасные похожие участки.
python crispr_finder_v2.py analyze ccr5.fasta --top-n 5 --check-offtarget
============================================================
Ген: CCR5_CDS Homo sapiens C-C motif chemokine receptor 5 (CCR5), mRNA
Длина: 1059 нуклеотидов
============================================================
Найдено кандидатов: 62
Топ-5 по скору:
1. GTCATGGTCATCTGCTACTC | score: 105.0 | GC: 50.0% | strand: +
Проверяю off-target (займёт ~30-60 сек)...
✓ Высокий риск off-target не обнаружен
2. GACAAGTGTGATCACTTGGG | score: 100.0 | GC: 50.0% | strand: +
Проверяю off-target (займёт ~30-60 сек)...
✓ Высокий риск off-target не обнаружен
3. ATGCAGGTGACAGAGACTCT | score: 100.0 | GC: 50.0% | strand: +
Проверяю off-target (займёт ~30-60 сек)...
✓ Высокий риск off-target не обнаружен
4. CAGTTTACACCCGATCCACT | score: 100.0 | GC: 50.0% | strand: +
Проверяю off-target (займёт ~30-60 сек)...
✓ Высокий риск off-target не обнаружен
5. GTAAACTGAGCTTGCTCGCT | score: 100.0 | GC: 50.0% | strand: -
Проверяю off-target (займёт ~30-60 сек)...
✓ Высокий риск off-target не обнаружен
Что можно запилить далее
Мы написали рабочий инструмент, но до уровня Benchling или CRISPOR ему далеко:
ML-скоринг - использовать модели типа Azimuth, обученные на реальных экспериментах
Локальный BLAST - скачать геном и искать без интернета и лимитов NCBI
Позиция мисматчей - мисматч рядом с PAM опаснее, чем на 5'-конце
Визуализация - карта гена с отмеченными сайтами
Batch-режим - много генов за раз с кэшированием
Но для понимания принципов - достаточно.
Итого
За две статьи мы:
Разобрались, как работает CRISPR-Cas9
Написали поиск PAM и извлечение gRNA
Добавили фильтрацию и скоринг
Подключили BLAST для проверки off-target
Протестировали на настоящем гене CCR5
Код в репозитории. Там же FASTA с CCR5.
Послесловие
Как я писал в комментариях к первой части, есть домашние лабораторные наборы с помощью которых можно сделать, например, светящееся пиво или что-то тревожно напоминающее биологическое оружие бактерию выживающую в стрептомицине (антибиотике). Если интересно про что-то такое (и сбоку пайтон) почитать, то голосуйте в опросе под статьёй, устрою в ближайшем будущем (постараюсь).
P.S. Биологам: простите за упрощения. Цель - показать принцип, а не заменить специализированные инструменты.
P.P.S. Не редактируйте людей. Даже если очень хочется. Особенно если очень хочется.
P.P.P.S. Обсудить или покритиковать - велком в телеграм.