동물 RNAi 발견 20년 만에 첫 약이 탄생하다

사람의 병은 어떤식으로든 단백질과 관련돼 있는 게 많습니다. 단백질은 모양이 중요하구요. 약은 단백질 사이에 어떻게든 비집고 들어가서 다른 단백질에 못 붙게 막아버린다거나, 단백질 모양을 바꿔버린다거나, 화학적 성질을 바꾸는 등, 모양에 딱 맞춰서 동작합니다. 아무데나 붙어서 엉뚱한 일을 하면 안 되니까요.

옛날부터 오랫동안 사람들은 미생물이 자기들끼리 싸우다가 만들어낸 화학물질을 항생제로 활용하고, 우연히 발견된 여러가지 천연물에서 순수정제한 여러 물질들을 쓰기도 했습니다. 사람이 단백질 모양을 보고 설계한게 아니라, 효과가 있는 물질을 찾은거죠. 그러다, 글리벡이라는 판을 바꾸는 약이 등장했습니다. 아예 단백질 모양을 보고 거기에 딱 맞는 화학물질을 설계해서 약으로 만들어버린거죠. 효과도 아주 좋아서, 덕분에 만성 골수성 백혈병 환자의 5년 생존률이 31%에서 59%로 올라갔습니다.


BCR-ABL 퓨전 단백질(초록색)과 글리벡(Imatinib; 빨간색) – from wikimedia, public domain

이제 단백질을 없애서 낫는 병이라면, 아무거나 단백질 구조만 있으면 무슨 약이든 만들 수 있게 됐…..으면 좋았겠지만.. 생각보다 단백질 구조는 그렇게 호락호락하지 않습니다. 구조를 정확하게 아는 것도 어려울 뿐더러, 거기에 딱 맞고 다른 곳에는 안 맞아서 엉뚱한 효과가 없는 쪼끄만 화학 물질을 만들기란 쉽지 않았죠. 요새 유행하는 항체 약도 한 방법이지만, 나름대로의 애로사항이 많습니다.

아니 그런데, 단백질은 어차피 RNA를 보고 만들고, RNA는 A, C, G, U 4가지 알파벳만 쓰는 그냥 1차원 문자열인 셈인데, RNA를 인식해서 없애면 결국 단백질을 없앨 수 있으니 훨씬 쉬운 것 아닌가요? 2000년에 동물 siRNA의 기본 원리를 발견한, 지금은 전설급인 과학자들 David Bartel, Phil Zamore, Thomas Tuschl과, 원래부터 전설이었던 Phil Sharp은 논문도 내기 전 부터 어디 쓰게 될지 감이 딱 와서 특허도 착착 바로 딱 내고, 회사도 차렸습니다. Alnylam Pharmaceuticals라고 멋진 이름도 지었습니다. RNA 서열은 단백질 구조에 비해 알아내기도 쉽고, 설계도 그냥 메모장 띄워놓고 해도 몇 분이면 할 수 있을 정도로 아주 쉽습니다. 모든 병을 치료할 수 있을 것만 같은 장밋빛 미래가 쫙 펼쳐졌죠!


siRNA(윗쪽 굵은 선)가 mRNA(아래 긴 선)에 붙어서 탁! 하고 부심. CC BY-SA 4.0 by Singh135

하지만 인생은 늘 쉽지 않습니다. 20~22글자 서열을 딱 인식해서 정확하게 딱 잘라버릴 것만 같았던 siRNA가 세포 안에만 들어가면 엉뚱한 친구들을 건드리기 시작합니다. 이것을 생물학자들은 오프타겟 효과라고 부릅니다. 이 오프타겟 효과 때문에 아주 골치가 아파집니다. A를 없애려고 딱 넣었더니 B, C, D, E, F, G, H, I도 서열이 조금 비슷하다고 같이 없어져버리고, 걔네들이 줄어드니까 또 다른 것들이 연달아 바뀌고.. 아주 정신이 없어졌습니다. 수많은 연구자들이 siRNA의 오프타겟 효과 때문에 인생의 적지않은 부분을 날려버렸습니다. -.-

게다가 예쁜꼬마선충이라는 귀여운 이름의 벌레와는 달리, 사람은 RNA을 정성들여서 먹어봐야 피에 들어가기 전에 다 소화돼버립니다. RNA는 꽤 많은 바이러스의 유전물질이기도 하기 때문에, 피에서도 그냥 둥둥 떠 다니면 이놈 바이러스 잡아라 하고 집중 공격을 받습니다. 먹어도 안 되고 주사해도 안 되면 어찌 하란 말인가 하는 문제가 또 있었습니다.

다시, 세상을 열심히 사는 과학자들이 나설 차례입니다. siRNA가 멋지게 좀 더 원하는 일만 하도록, 피에 딱 찔러 넣으면 멀쩡히 오랫동안 버티고, 세포 안으로도 쉽게 쏙 들어가도록 수많은 변형을 시험해 봅니다. 하지만, 거의 15년 동안 대형제약회사들을 포함한 많은 투자자들이 돈을 쏟아부었지만, 싹이 노란 채로 더욱 더 노래지기만 하고 있었습니다. 그 와중에 다행히도 몇 개 성공 가능성이 보이는 약이 등장했습니다.


TTR 단백질 4개가 붙어서 생긴 정상 구조 (1번), 혼자 떨어진 TTR (2번), 어쩌다 잘못 접힌 TTR (3번), 잘못 접힌 TTR이 마구 붙어서 거대한 아밀로이드 원섬유가 돼 버린 구조 (4번) – (c) J. Kelly, The Scripps Research Institute.

자잔~ 바로 며칠 전에 임상시험 거의 마지막 단계인 3상을 성공한 파티사란(patisiran)입니다. 파티사란은 가족성 아밀로이드증 (hATTR)이라는 희귀 유전질환을 치료하는 약입니다. 환자들은 TTR이라는 단백질에 변이가 있어서, 이상한 모양으로 꼬여서 세포에 계속 쌓이기만 하고 없어지지 않습니다. 그 결과, 녹내장, 치매, 발작, 부정맥, 신부전, 부종, 요로감염, 이한증 등 거의 온몸에서 문제가 생겨서 대체로 오래 살지 못하고, 살더라도 삶의 질이 극도로 떨어지는 심각한 상황이 됩니다. 파티사란은 TTR 단백질을 만드는 mRNA를 없애도록 설계된 siRNA 약입니다. 멋지게 지방 나노입자 (lipid nanoparticle)에 쌌고, 좀 더 안정성있게 식물에서 많이 사용하는 2′-O-methylation이라는 변형 RNA 화학구조도 도입했습니다. 그 결과 3상에서 p-value 0.00001로 증상 개선에 효과 있다는 결론이 나왔습니다!

Alnylam은 단박에 주가가 40%오르고 hATTR 환자들은 처음으로 쓸만한 약을 얻었습니다. 아마도 약값은 무진장 비싸겠지만 말이죠;;

~.~.~.~.~.~.~.~,~.~.~.~.~.~.~.~,~.~.~.~.~.~.~.~,~.~.~.~.~.~.~.~

Patisiran은 TTR 3′ UTR을 targeting하는 siRNA입니다. 요즘 나오는 사이보그 수준의 engineering이 들어가지 않은, 보통 우리가 실험에 많이 쓰는 19-mer duplex 뒤에 dT-dT 붙어있는 전통적인 siRNA 구조입니다. mRNA target과 binding affinity도 높이고 stability도 올리는 목적으로 2′-O-methylation이 guide에 2개, passenger에 9개 되어 있습니다. 올 초에 출시된 oligonucleotide drug인 SPINRAZA가 모든 2′ 위치에 평소에 연구용으로는 거의 보기 힘든 2-methoxyethyl을 붙이고, backbone도 O 하나를 S로 교체한 것에 비해 상당히 단순합니다.

5′-------A--U-G--G--A--A--Um-A--C-U-C-U-U-G--G--U-Um-A--C-dT-dT-3′
3′-dT-dT-Um-A-Cm-Cm-Um-Um-A--Um-G-A-G-A-A-Cm-Cm-A-A--Um-5′

보통 off-target 효과를 분산해서 전체적으로 줄이려고 siRNA를 여러 개 짜서 섞어서 쓰는 전략도 많이 쓰는데, patisiran은 약이라서 그런지 딱 1개만 썼네요. Target 지역은 stop codon에서 50nt 정도 떨어진 지역입니다. 아무래도 TTR mutation이 주된 2-3가지 타입 외에도 여러가지가 있고, 노인성 아밀로이드증은 아예 wild-type이 쌓이기도 하니까, 최대한 모든 TTR을 커버하려는 것 같습니다. TTR 3′ UTR 앞 쪽은 그 흔한 SNP도 보고된 것이 거의 없네요.


TTR stop codon 근방과 3′ UTR 지역. 맨 위 YourSeq으로 표시된 부분이 patisiran이 타겟하는 부분. (by Hyeshik, CC BY-SA 4.0)

patisiran이 인식하는 부위는 딱 siRNA 디자인의 정석에 해당하는 자리입니다. Seed 서열이 off-target 효과에 가장 결정적인데요, patisiran의 경우는 UGGAAU입니다. 기존의 많이 발현되는 miRNA 중에 완벽하게 같은 seed를 갖고 있는 것이 없고, 1개 차이 나는 것 중에서도 많이 발현되는 녀석들은 passenger strand 뿐입니다.


patisiran과 seed가 전체 또는 1nt 차이로 겹치는 miRNA들. passenger도 포함되어 있으니 주의. (by Hyeshik, CC BY-SA 4.0)

그리고, target 지역의 2차 구조가 문맥에 관계 없이 어떤 상황이더라도 비슷한 구조로 딱 접혀있어야 dose 조절이나 약동학적 분석이 쉬워질텐데요. 17bp 정도 되는 stem loop에 딱 siRNA guide가 맞게 디자인 됐습니다. loop 부분과 bulge, 2′-O-Me가 있으니까 열역학적으로도 충분히 stem loop을 풀고 binding할 수도 있을 것 같네요.


TTR 3′ UTR 일부. patisiran에 상보적인 부분이 대문자로 표시되어 있음. (by Hyeshik, CC BY-SA 4.0)

Alnylam은 똑같은 TTR을 타겟으로 다른 chemical modification과 다른 delivery agent로도 임상시험을 병렬로 진행했었는데, 지금은 모두 중단되었다고 합니다. 모든 측면에서 더 전통적인 방법으로 만든 게 결국 더 성공적이었네요~ 활발히 개발되고 있는 다른 siRNA drug들도 앞으로 끝까지 살아남는 녀석들이 많기를 기원해 봅니다. ^_^

옥스포드 나노포어 12월 제품 업데이트

2007년 스티브 잡스 발표 기억하시나요? 화면에 전화기, 터치스크린 아이팟, 인터넷 장비 셋을 띄워놓고 빙글빙글 돌리면서 “Are you getting it?” 하다가 아이폰을 뿅! 하고 발표한 그 맥월드 키노트 말이죠. 이 키노트 이후로는 IT에 크게 관심 없는 사람도 애플이 신제품에 대해 발표를 할 때마다 괜히 설레며 새벽에 보기도 하고, 아침에 기사 검색도 해 보곤 합니다. 요새 바이오텍 업계에서 그런 회사가 하나 등장했습니다. 바로 DNA시퀀서를 만드는 옥스포드 나노포어 (ONT)입니다. 이 회사에서 제품 업데이트를 발표할 때마다 수천 명이 라이브스트림으로 보고, 트위터가 떠들썩해집니다. 아직 엔드유저 제품도 아닌 걸 고려하면 굉장한 반응입니다.

ONT는 보통 분기당 한 번씩 제품 업데이트를 발표하고 있습니다. 업데이트가 너무 빨라서 관심을 기울이고 있어도 따라가기 힘들 지경인데요. 신제품 소식을 듣고 주문하고 받아서 실험하고 잠깐 분석 좀 하다가, 다음 실험을 위해 또 주문하려고 가 보면 전에 주문했던 버전은 벌써 단종되고 없을 정도입니다. -.- 얼마 전 12월 2일까지 뉴욕에서 열린 NanoporeConf에서도 아주 재미있는 소식을 많이 발표했습니다. 간단히 요점만 줄여서 소식 전해드립니다.

PromethION

ONT의 가장 큰 시퀀서 제품인 PromethION이 드디어 첫 고객들한테 발송됐습니다. 역시 첫 고객들은 충성스러워서 다들 트위터에 자랑하고 돌려보기도 전에 벌써 대단한 데이터를 얻은 것처럼 흥분했더군요. ㅎㅎ 새로 공개된 스펙은 다음과 같습니다. 플로우 셀 수는 48개가 됐고요. 플로우 셀 마다 6000 채널 시퀀싱이 가능합니다. 현재 MinION 플로우셀은 512채널에 2048웰 이니까, 플로우 셀 기준으로 비교하면 대략 MinION의 최대 600배 쓰루풋을 낸다고 보면 되겠습니다. 그리고, 이 정도 채널 수가 되면 USB 3.0으로는 도저히 수용 불가능해서, 내장 컴퓨터가 들어가 있습니다. 여기에는 FPGA기반 베이스콜링 가속기가 포함된다고 하는군요. 그리고 가격은 연간 사용료 15000달러로 정했다고 합니다. 얼마 전에 ONT에서는 MinION은 기계 값은 완전 무료, PromethION은 기계 값은 무료로 하고 사용료만 받겠다고 발표했었습니다.

새 시퀀싱 케미스트리: 1D²

지난 3월에 CsgG 포어를 도입하면서 정확도가 드디어 제정신으로 쓸 수 있는 수준까지 올라왔는데요. 그 후에도 CsgG 뮤턴트를 계속 만들면서 더 정확하고 빠르게 개선하고 있다고 합니다. 지금 시험 중인 뮤턴트 하나는 기존 R9 포어보다 거의 5배 정도 더 잘 DNA가 들어간다고 하는군요. (지나가는 속도는 그대로이고 포어가 비어있는 시간이 줄어 듦)

기존의 1D, 2D 시퀀싱에 새로 1D² 시퀀싱이 추가됐습니다. 원래 헤어핀 어댑터를 이용해서 끝에서 한 바퀴 휙 돌아 2번 시퀀싱 하는 2D가 2번째 가닥의 시퀀싱 품질이 매우 안 좋았었는데요, 양쪽 모두 기존에 1D에서 쓰던 Y 어댑터를 쓰면서 모터 단백질과 테더링을 개선해서 첫 번째 가닥을 읽고 다 읽은 가닥이 떨어져 나간 뒤에 바로 반대쪽 상보 가닥이 들어가도록 했다고 합니다. 원래 2D에서 90-97%까지 정확도가 왔다 갔다했던 게, 1D²에서는 95-97% 정도로 아주 고르게 나오게 됐습니다. 따로 언급은 안 됐지만 라이브러리 프렙도 1D와 거의 같을 것이기 때문에 시간도 절약되고, 손실도 줄고, 그동안은 2D에 쓸 수 없었던 tagmentation 기반의 간단한 프렙 장치들도 쓸 수 있게 될 것 같네요.

베이스콜링 업그레이드: 긴 호모폴리머

올해 초까지 HMM을 쓰다가 RNN으로 바뀐 뒤 성능이 크게 올라갔었는데요. 구조상 HMM이나 RNN 기반 베이스콜러 모두 5nt 초과 호모폴리머는 베이스콜링이 불가능했습니다. 이 문제를 대폭 개선한 새로운 Transducer라는 베이스콜러가 처음 소개됐습니다. 일루미나 시퀀싱 베이스콜러 중에 베이스콜링이 어려운 라이브러리들을 잘 처리해줘서 유명했던 AYB를 만든 Tim Massingham이 ONT에 가더니만 결국 전공을 살렸네요. ㅋㅋ 10nt 정도 되는 homopolyer를 시험해 본 결과 30% 정도 길이가 짧아지는 경향은 있지만 거의 선형적으로 비례관계가 있도록 재고는 있다고 합니다. 조금 더 개선 되면 일반 배포한다고 하네요.

새로운 저가형 플로우셀: Flongle

MinION이 기계는 공짜인데, 플로우셀이 너무 비싸죠. 그래서 전자장치를 모두 계속 쓸 수 있는 어댑터로 빼 버리고, 막과 단백질, 플라이스틱만 남겨놓은 소형 플로우셀을 새로 만들었다고 합니다. 휴대전화용 시퀀서인 SmidgION 플로우셀과 똑같은 거라고 하네요. 가격은 클로닝할 때 플라스미드 시퀀싱 하는 가격과 비교할 수 있을 정도로 맞춘다고 하니, 쓰루풋이 반 정도로 낮긴 하지만 기존의 MinION 플로우셀을 완전히 대체할 수도 있을 것 같습니다.

피를 넣으면 라이브러리가 돼서 나오는 필터 팁: Zumbador

지난 5월 런던콜링에서 엄청 떠들썩하게 발표됐던 Zumbador가 좀 더 구체적인 모습을 드러냈습니다. 라이브러리 프렙 전 과정을 실온에서 할 수 있다고 하고요. Tagmentation기반 1D 프로토콜로 만든다고 합니다. P1000 팁보다도 작은 크기로, 위에 피를 떨어뜨리면, 아래로 라이브러리가 나옵니다. ㅎㅎㅎ 그리고, 피 외에 다른 곳에서도 DNA나 RNA를 뽑을 수 있도록 bead beater라는 초소형 장치를 또 새로 공개했습니다. 식물 잎이나 씨앗, 섬유 같은 곳에서 아무 것도 없이 물만 더 있으면 DNA를 뽑아서 바로 Zumbador에 넣어서 라이브러리를 만들 수 있다고 하네요. 낯선 곳에 갔는데 뭔가 의심스러운 것이 있으면 바로 시퀀싱 해보고 안심(?)할 수 있다고 하는군요. ㅎㅎ 음식점에 가서 원산지를 믿을 수 없거나 청결을 믿을 수 없는 음식 먹기 전에 시퀀싱 해 보는 사람도 나올 것 같습니다. -o-;

선택적 시퀀싱

지난번에 공개했던 Cas9 기반 enrichment가 좀 더 구체적으로 공개됐습니다. 효소 활성이 없는 Cas9에 붙여서 이 Cas9을 가지고 면역침전이나 비슷한 방법을 이용해서 정제하는 것이 기본 아이디어인데요. 이번에 공개한 결과를 보면, Cas9가 붙은 채로 플로우셀에 들어가도 모터 단백질이 그냥 밀고 지나가서 전혀 시퀀싱에 문제 없다고 합니다. 그래서 tagmentation과 동시에 Cas9을 처리할 수 있고 그 후 정제도 단순하다 보니 기존의 다른 키트들보다 훨씬 간단하게 돼 버렸네요. 1시간 이내에 DNA부터 시작해서 enriched 라이브러리가 나오게 돼 버렸습니다.

리드를 조금 읽다가 rRNA같이 안 읽어도 되는 시퀀스다 싶으면 바로 반대로 전기를 걸어서 뱉어내 버리는 Read Until도 약간 소개했는데요. 자세한 것은 이미 전에 다 공개됐던 것이고, API를 통해서 사용자 프로그램이 직접 MinKNOW에 연결해서 돌게 된다고 하네요. 그런데 그 이후로 모터 단백질이 450bps로 속도가 올라가는 바람에.. 이제 한 10kbp정도는 돼야 좀 뱉어도 뱉는 것 같지 짧은 건 프로그램에서 뱉어내라고 하면 웬만하면 “이미 다 지나가고 없는디? =.=”하고 포어가 당황하게 생겼어요.

최초의 ~~~~ 지놈: Cliveome

ONT 제품 업데이트 발표 때마다 카리스마를 발산하고 있는 ONT의 간판스타 Clive Brown이 자기 지놈을 시퀀싱 해서 공개했습니다. 인류 최초로 “자기” 피에서 DNA를 뽑아서 스스로 라이브러리를 만들어서, 시퀀싱해서, 어셈블리해서 공개하는 개인 유전체라고 하네요. James Watson이나 Craig Venter도 피만 줬지 자기 손으로 시퀀싱하고 라이브러리 만들지는 않았죠. ㅎㅎ

MinION 36개 플로우셀로 150Gb를 뽑아냈고, 25kb이상 long read가 30Gb 나왔다고 합니다. haplotype assembly를 만들려면 이 정도는 돼야 하겠지만.. 지금 시퀀스 정확도로 잘 될지는 의문이네요. 그래도 다른 시퀀서에서는 할 수 없었던 시그널 수준 취합이 가능하니까 앞으로는 꽤 괜찮아질지도 모르겠네요. 트잉여답게 피 뽑는 순간 부터 자기 피 뽑는다고 긴장된다고 트위터에 엄살 생중계를.. ㅋㅋ; 앞으로도 자기 VDJ지역 대상으로 time-course immunoprofiling을 꾸준히 해서 계속 공개한다고 하고요. Zumbador, Cas9 enrichment 등등 새로 제품 개발되는 것마다 나오면 다 시험삼아 다 해 보고 Cliveome을 꾸준히 업데이트하겠다고 하네요. 데이터는 어제 GitHub에 모두 공개되었습니다.

이 외에도 direct RNA sequencing과 또 다른 여러 응용 분야에 대해 재미있는 발표가 많이 올라왔습니다. 관심 있는 분은 https://vimeo.com/user5318092 여기에서 살펴보세요~

영국사람들이 RNA를 직접 시퀀싱하는 방법

RNA를 시퀀싱하는 걸 RNA-seq이라고 부릅니다. 그런데 RNA-seq할 때 RNA를 시퀀싱하지는 않죠. (엥 이게 뭔 소리.?) 요즘 시퀀싱 업계 최고의 떠오르는 별 옥스포드 나노포어가 “최초로” RNA를 대규모로 시퀀싱하는 기술을 만들어서 프리프린트를 냈습니다. 어떤 일이 있었는지, 어떤 건지 한 번 알아봅시다~!

RNA-seq은 보통 여러 RNA의 양을 재거나, 시작이 어딘지, 끝이 어딘지, 스플라이싱이 어떻게 되는지 볼 때 씁니다. RNA는 단백질 결합이나 자르기 붙이기 구조 바꾸기 등 변화무쌍한 녀석이라, 여러 실험적 전처리를 거쳐서 온갖 변형된 RNA-seq이 나왔죠. RIP-seq, CLIP-seq, SHAPE-seq, TAIL-seq, small RNA-seq, ribosome profiling, 3P-seq, lariat sequencing, degradome-seq 등 아주 특징적으로 다른 놈들만 쳐도 금세 10개가 넘어갑니다.

그런데, 알고 보면 이 수많은 방법 중에 RNA를 시퀀싱하는 놈은 하나도 없습니다. 다 cDNA를 만들어서 증폭해서 DNA를 시퀀싱하죠. cDNA나 RNA나 결국 그 놈이 그 놈 아닌가 싶지마는, 변환 과정에 꽤 많은 정보를 잃어버립니다. 우선, 증폭. RNA에서 DNA를 만들어서 시퀀싱하기 좋은 형태로 딱 만들어주는 “라이브러리 프렙” 과정은 효율이 낮은 스텝이 많이 껴 있습니다. 그래서 증폭 없이는 기존 2세대 시퀀서는 거의 깨끗하게 돌릴 수 없는데요. 문제는 어떤 놈은 100배로 증폭되는 사이, 다른 놈들은 2배로도 증폭이 안 되는 일도 흔하다는 거죠. 이 문제가 가장 심각한 마이크로RNA 시퀀싱에선, 다른 RNA 2가지를 똑같은 양으로 넣고 라이브러리를 만들어도 결과는 100배 넘게 차이 나는 경우가 뭐 말할 필요도 없이 늘 있는 일입니다.

그리고, DNA로 변환하는 과정 중에 RNA의 화학적 수식 정보를 다 잃어버립니다. 후성전사체(epitranscriptome)가 요새 RNA쪽에서 핫한 키워드인데요. 최근 3년 간 mRNA에서도 N6-methyladenosine, N1-methyladenosine, pseudouridine, 5-methylcytosine이 발견되고 논문이 쑥쑥 잘 나오면서 RNA쟁이들이 수식된 RNA를 어떻게든 보려고 노력을 많이 하고 있죠. 그런데 DNA로 변환을 하게되면 그냥 밋밋한 A, T, C가 돼 버려서 재미가 없어집니다. 궁색하게 전처리를 어떻게든 해야 하는데 그래도 썩 마음에 들지는 않죠.

RNA쟁이들은 오랫동안 RNA를 있는 그대로 처음부터 끝까지 쭉쭉 읽어내면 얼마나 좋을까 하고 꿈꿔왔습니다. 3세대 시퀀서 중 가장 먼저 떴었던 HelicosPacBio도 그래서 direct RNA sequencing을 처음부터 그렇게 밀었죠. 리드 길이가 긴 것은 좋았지만, 역전사는 둘 다 피할 수 없었습니다. PacBio에서 methyladenosine을 구분할 수 있다는 논문도 몇 개 나오긴 했지만, 하기도 어려운데다 구별도 잘 안 되었습니다.

짜잔. 그래서 역전사가 필요없는 나노포어에서 멋진 기술을 내놓았습니다. 10명이 넘는 꽤 큰 팀을 오랫동안 운용해서 재작년부터 정보를 조금씩 흘리기 시작했죠. 올해 6월에는 direct RNA sequencing의 베타 프로그램을 시작했습니다. 8월에는 프리프린트 서버인 bioRxiv에 논문을 올렸습니다. 사실 논문이라고 부르기는 좀 부끄럽고 그냥 광고 내지 찜 정도로 봐 줄 수 있겠습니다. 메쏘드 부분이 전혀 구체적이지 않고, 대부분 정보를 숨긴데다가, 성능 평가 부분도 그냥 두루뭉술하게 퉁치고 지나가버렸습니다. 그래도 새로 공개된 정보가 많으니 한 번 자세히 뜯어봅시다~

나노포어는 다른 시퀀싱 방법들과 달리 방향을 마음대로 할 수 있습니다. 5′부터 읽을 수도 있고 3′끝 부터 읽을 수도 있죠. 방향에 따라 라이브러리 만드는 방법이 전혀 달라지고 나오는 시그널도 전혀 다르니 어디서부터 읽을지 잘 골라야 하죠. 상용화된 DNA 시퀀싱 키트에서는 5′부터 읽게 되어있는데, RNA sequencing에서도 작년 5월에 발표된 자료까지만 해도 5′부터 읽게 되어 있다가, 이번에 3′부터 읽는 것으로 바뀌었습니다. 아직 최종적으로 상용화 버전에서 어느 방향을 쓸지는 확정되지는 않았는데요. 5′부터 읽는 게 엄청나게 시그널 특성이 좋지 않는 한, 그냥 3′->5′을 유지할 가능성이 높습니다. Direct RNA sequencing에서는 양쪽 끝 중에 한 군데만 어댑터를 붙이면 되는데요, 5′끝보다 3′끝이 쓸 수 있는 무기(효소)도 훨씬 많고 5′캡이 막아주는 덕분에 간단한 프로토콜 만들기가 쉽죠. 그래서 이번 논문에서 쓰는 프로토콜은 이렇게 어댑터를 붙입니다.

나노포어 direct RNA sequencing 라이브러리 만드는 방법 중 하나
나노포어 direct RNA sequencing 라이브러리 만드는 방법 중 하나. Giralde et al. (2016) doi:10.1101/068809

이렇게 붙여서 나노포어 플로우셀에 넣으면 회색 반지모양으로 그려진 단백질이 RNA쪽 가닥을 잡고 조금씩 놓아주면서 나노포어에 통과시켜주게 됩니다. 이 단백질을 나노포어에서는 모터 단백질(motor protein)이라고 부르는데요. 모터 단백질을 쓰지 않으면 DNA나 RNA가 신호를 잡을 수 없을 정도로 너무 빨리 통과해버리기 때문에 신호 분석이 불가능합니다. 그렇다고 무한정 느리게 잡고 있으면 단일가닥 DNA나 RNA가 스스로 접히는 2차 구조나 랜덤하게 움직이는 신호까지 잡히는데다 일정 시간동안 통과하는 DNA/RNA 개수도 줄어들게 됩니다. 그래서 너무 빠르지도 않고 너무 느리지도 않은 기가 막힌 속도로 살짝 잡고 놓아주는 게 중요합니다. DNA를 5′에서 3′로 보내면서 살살 놓아주는 것과 RNA를 3′에서 5′로 보내면서 살살 놓아주는 것은 전혀 다른 얘기라서, 이번엔 이 모터 단백질도 바꿨다고 하네요. 하지만 구체적인 정체는 숨기고 있습니다.

자 이제 이렇게 시퀀싱이 됐으면, 베이스콜링 정확도는 얼마나 될지, RNA 화학적 수식은 잘 잡을 수 있는지가 모든 사람이 궁금해 하는 지점이 됩니다. 옥스포드 나노포어 R9의 DNA 시퀀싱 정확도는 1번 읽었을 때 85% 정도, 앞뒤로 2번 읽었을 때 95%로 알려져 있습니다. 자 그럼 direct RNA는…….? 대략 80% 된다고 합니다. -O-; 그런데 좀 그런게, 전체 리드 대상이 아니라 아주 전형적인 예라면서 특정 리드 1개만 보여주고 정확도를 80%라고 추정하고 있습니다. 어디서 사기를.. ㅋㅋ 전체로는 GAPDH 리드들을 모두 모아서 대략 96% 정도 시퀀스가 서로 같은 isoform 둘 중에 어느 것인지 매핑하면, 하나로 거의 확실히 구분할 수 있을 만큼은 된다고 합니다. (ㅎㅎㅎㅎ)

DRS에서 나온 서열 중 하나의 정렬. Giralde et al. (2016) doi:10.1101/068809
DRS에서 나온 서열 중 하나의 정렬. Giralde et al. (2016) doi:10.1101/068809

아직은 확실한 레퍼런스 DNA 또는 전사체 레퍼런스가 있을 때 아니면 쓰기가 어렵겠는데요. 그래도 열심히 align하면 대충 스플라이싱 구조 정도는 알아볼 수 있을 것 같습니다. 그렇다면 화학적 수식이 있는 것들은 구분이 될까요? 이거라도 잘 돼야 할텐데요.

나노포어 DRS에서 나온 m6A 주변 신호. Giralde et al. (2016) doi:10.1101/068809
나노포어 DRS에서 나온 m6A 주변 신호. Giralde et al. (2016) doi:10.1101/068809

다행히도 m6A는 위에서처럼 구분이 아주 잘 되네요. 나노포어에 통과하고 있는 베이스 외에 주변에 있는 녀석들도 전기전도도에 영향을 좀 미치다보니, 나노포어 신호는 주변 서열 영향을 많이 받는데요. 다른 서열 사이에 껴 있는 m6A도 구분이 잘 된다면 좋겠네요. 5월에 옥스포드 나노포어 사용자 모임(?)인 런던 콜링에서 Mark Akeson이 tRNA의 경우에는 알고리즘을 열심히 트레이닝하면 tRNA에 있는 각종 다양한 수식도 구분할 수 있다는 걸 보여줬으니, 이런 저런 데이터를 계속 쌓다보면 mRNA에서 다른 수식들도 잘 다룰 수 있게 되지 않을까 봅니다.

옥스포드 나노포어 R9의 주요 업그레이드 중에 딥 러닝 알고리즘(RNN)을 도입한 것이 있습니다. 이번 direct RNA sequencing에서는 RNN 대신 기존에 쓰던 HMM을 썼기 때문에, 베이스콜링 정확도나 화학적 수식 모두 개선의 여지가 있습니다. 아직 트레이닝도 다양한 상황에서 충분히 되지는 않았구요. 아마도 엔드 유저 입장에서 완전히 베이스콜링 된 것을 쓰자면 시간이 꽤 더 걸리겠지만, 신호 수준에서 분석하는 걸로는 지금도 RNA쟁이들에게 좋은 무기로 쓰일 수 있을 것 같네요.

참고로 (혹시나 궁금한 분이 있을까봐) 아직 옥스포드 나노포어는 IPO를 하지 않았습니다. (투자는 거의 20년에 걸쳐서 엄청 받았죠. ㅋㅋ)

앞으로 태어날 우리 아기, 데이터로 미리 만나볼까~

여기에서 노트북 형식으로도 볼 수 있습니다. 주피터 노트북에서 직접 써 보고 싶은 분은 여기에서 받으실 수 있습니다.

장혜식
http://highthroughput.org/

[알림 1]
이 글에서 소개하는 정보와 소스코드, 방법론 등은 모두 연구 또는 학술 목적으로만 사용 가능합니다. 의료용으로 사용하실 때는 의료기관을 통해 법률에서 정하는 절차로 사용하셔야 합니다.

[알림 2] 이 노트북은 유전학은 잘 모르지만 데이터 분석에 관심이 많은 분들과, 데이터 분석은 잘 모르지만 유전학은 잘 아는 분들도 큰 사전지식 없이 읽으실 수 있게 쓰려고 노력했습니다. 그래서 정확성을 희생하고 간단한 말로 대체한 부분이 많으니 제대로 된 정의에 대해서는 논문들을 참고하시길 부탁드립니다. 사실 저도 인간유전체학을 하진 않기 때문에 정확히는 잘 모릅니다. 하핫;;

[알림 3] 이 노트북에서 사용된 시퀀싱 데이터는 테라젠바이오연구소에서 만들어주셨습니다.

NIPT (non-invasive prenatal testing)는 임신 초기에 아기의 유전형을 검사하는 가장 최근에 나온 방법이다. 이름이 비침습적이라고 진짜 아무 곳도 안 찌르고 들어가는 건 아니고, 엄마 피를 뽑아서 분석한다. 그래도, 아기는 안 건드리기 때문에 양수 검사융모막 검사처럼 문제가 생겼을 때 아기에게 해가 가지는 않는다.

엄마 피로 도대체 아기의 유전자를 어떻게 검사하나? 놀랍게도(!) 임신 10-14주 정도 되는 엄마 피에 떠다니는 DNA의 대략 5-20% 정도는 아기 DNA라고 한다. 정확하게 말하면 엄마 피에 있는 DNA 중에 백혈구 같은 세포 안에 들어있는 DNA 말고, 둥둥 밖에 떠다니는 DNA 중의 5-20% 이다. 이 아기 DNA를 잘 분석하면 아기가 어떤 유전형을 가졌는지 아기를 건드리지 않고도 확인할 수 있다는 것이 NIPT의 기본 개념이다.

이론 상으로는 이런 방법으로 아기의 정말 자세한 유전형을 알아낼 수도 있지만, 엄마 DNA가 무려 90% 가까이 차지하고, 나머지 10% 가량만 아기 DNA라서 현실적으론 어렵다. 아기 DNA에 대한 커버리지만 충족시키는데도 기본적인 전체 유전체 시퀀싱(WGS)보다 10배 이상 드는데, 게다가 엄마DNA와 섞여있다보니 어느게 누구 것인지 통계적으로 확실히 구분할 수 있는 수준까지 가자면 일반적인 WGS의 수 백배가 필요하다. 다른 것 다 빼고 시퀀싱 시약 비용만 억대가 넘어간다.

그래서 지금 상용화되어 있는 NIPT에서는 다른 것은 모두 포기하고 염색체 개수가 이상한 이수성(aneuploidy)만 검사한다. 이수성으론 가장 유명한 것이 21번 염색체가 2개가 아니라 3개가 들어가는 3염색체성(trisomy 21)인데, 이 문제가 생기면 거의 대부분 다운 증후군이 나타난다. Trisomy 21은 생각보다 자주 발생하는 현상이라 완전 남 얘기가 아니다. 40대 초반 산모에서는 1%까지 빈도가 올라간다.

NIPT로 aneuploidy는 어떻게 검사할까? 현재 업계에서 쓰는 방법에선 0.1-0.5X 정도의 커버리지로 전체 유전체를 임의로 찔끔찔끔 읽어낸다. 즉, 엄마 피에 돌아다니는 DNA 조각 중에 아무거나 골라서 그게 뭔지 A/C/G/T가 연속되는 문자열로 읽어내는데(sequencing), 이걸 이미 알고 있는 사람 DNA 전체 문자열(서열)에 딱 맞는 부분을 찾아서 배열했을 때 대충 10%(0.1X)에서 50%(0.5X)정도 덮일 정도로 살짝 읽어내는 것이다. 많이 읽을 수록 커버리지가 올라가고 데이터도 더 좋아지지만, 돈이 많이 든다.

커버리지가 워낙 낮다보니 뭐 다른 건 분석할 수 있는 게 거의 없고, 사람 DNA 전체에서 일정 간격으로 자른 구간에서 DNA가 몇 번 읽혔는지 세서 그 숫자를 잘 비교하는 수 밖에 없다. 아기 DNA가 10%, 엄마 DNA가 90% 인 샘플이라면, 아기 DNA가 정상적인 염색체에 비해 trisomy가 있는 염색체는 5% 정도 더 읽히게 된다. 염색체가 1벌만 들어가는 monosomy가 발생했다면 5% 적게 읽힌다.

밤톨이의 등장

원래 넷을 낳겠다는 아내의 원대한 계획에 발 맞춰 얼마 전 둘째가 생겼다. 장모님이 밤이 후두둑 떨어지는 걸 받는 꿈을 꿨다고 해서 태명이 어쩌다가 밤톨이가 됐다. 원래 병원을 별로 좋아하지 않는 아내지만, 집 앞에 있는 병원의 시스템이 특별히 ESC를 누르지 않으면 저절로 OK가 계속 눌리는 프로그램처럼 딱히 얘기하지 않아도 자꾸 검사를 하게 돼 있어서 초기 검사를 꽤 했다. 12주엔 목둘레 투명대 검사(nuchal scan)를 했는데 한참을 재더니 재는 각도에 따라서 3.0mm이 넘기도 하고 아무리 좁게 잡아도 2.7mm은 항상 넘는다고 했다. 이 두께로 나오면 여러가지 염색체 이상이 발생했거나, 앞으로 신경관/심장 발달에 문제가 있을 가능성이 높다고 양수검사나 융모막검사를 하자고 했다.

그런데 이 검사들은 침습적 검사라서 실패하면 유산 가능성이 있고, 그 가능성이 1%를 넘기도 한다. 너무 위험한 검사라서 안 그래도 하루 종일 걱정을 하는 아내가 더욱 걱정을 하기 시작했다. 아내는 며칠동안 하루 종일 목둘레 검사 결과가 안 좋았지만 정상적으로 출산한 산모들과 양수검사를 하다가 사고가 난 엄마들의 인터넷 글들을 검색해서 읽었다. 정말 끝도 없이 나왔다. -O-; 여러모로 알아보다 기저율이 그렇게 높지 않은 나이이니까 문제가 없을 가능성이 훨씬 높다고 생각하고, 위험을 무릅쓸 필요는 없겠다 싶어서 그냥 추가 검사는 하지 않고 이전 검사 결과는 그냥 무시하기로 했다.

그러던 중에, 테라젠에서 NIPT검사에 관련된 논문을 냈다는 소식을 들었다. 예전에 같이 연구했던 친구들이 요즘 회사에서 NIPT사업화 관련해서 연구를 많이 하고 있다는 얘기도 듣고, 가장 활발하게 시퀀싱 사업을 하고 있는 Illumina에서도 요즘 NIPT 프로토콜과 제품을 많이 개발하고 있던게 생각났다. 그래서 NIPT 방법에 대해서 공부도 좀 해 보고, 인간 유전체 분석은 한 번도 안 해 봤기에, 그것도 어떤 방법으로 하는지 궁금해서, 테라젠의 김태형 이사님께 연락해서 아내의 NIPT 검사 데이터를 만들었다. 아직 태어나지도 않은 아이를 DNA 데이터로 먼저 만나다니! 정말 공부도 쏙쏙 되고, 뭔가 획기적인 결과를 하나 만들 수 있을 것만 같았다. ㅋㅋ

기술적으론, 아내의 피에서 뽑은 세포 밖 DNA를 Ion Proton으로 0.3X 정도 읽어서 각 염색체에 매핑되는 리드 개수를 몇 가지 보정한 후에 서로 다른 염색체들끼리의 리드 개수 비율을 구한다. 그 비율은 염색체별로 시퀀싱 효율 차이가 있기 때문에, 똑같은 DNA 준비 과정과 똑같은 시퀀싱 플랫폼을 거친 다른 임신부들의 검사 결과를 백그라운드 분포로 잡고 비교하는 방식으로 이수성의 가능성을 계산한다.

리드 매핑하고 bin에 들어가는 리드 세기

먼저 시퀀싱센터에서 받은 데이터를 매핑해서 지놈 전체를 100kbp 단위 빈으로 나눠서 리드를 센다. 빈 크기는 50k부터 300kbp까지 다양하게 많이 쓰이지만, 이번 데이터 경우에는 100kbp가 노이즈는 적으면서도 충분한 빈 개수를 확보할 수 있는 적당한 수준이었다. 사실 빈 크기가 결과에 큰 영향을 주지는 않는다.

시퀀싱 기계에서 읽은 DNA 서열을 이미 알려져 있는 사람 DNA 서열 어느 부분에 해당하는지 찾아보는 매핑 과정과 각 빈에 들어가는 리드 수를 세는 과정은 snakemake로 파이프라인을 간단하게 만드는 게 더 쉬우니까 이걸로 일단 돌린다. 매핑은 웬만한 프로그램과 비교해선 진짜 말 그대로 100배 이상 느린 것으로 유명한 GSNAP으로 한다. 보통은 BWAbowtie2 같은 것을 많이 사용하는데, 리드 개수도 얼마 안 되고 CPU와 메모리가 아주 펑펑 써도 충분할 정도로 남아 돌아서.. ㅎㅎ

우선 데이터가 있는 곳으로 뿅

In [2]:
%cd ~/tmp/niptraw
/atp/hyeshik/tmp/niptraw

아래는 Snakefile 얼개 그림과 내용. Snakefile 맨 윗쪽에서 쓰고 있는 powersnake 모듈은 여기에서 받을 수 있다.

In [31]:
!snakemake --rulegraph | dot -Tpng > rule.png

from IPython.display import Image
Image(filename='rule.png')
Out[31]:
In [32]:
!cat Snakefile
from powersnake import *

SAMPLES = ['bamtori']
BINWIDTH = 100000
GENOME_FASTA_URL = 'http://hgdownload.soe.ucsc.edu/goldenPath/hg38/bigZips/hg38.fa.gz'
GENOME_CHROMSIZES_URL = 'http://hgdownload.soe.ucsc.edu/goldenPath/hg38/bigZips/hg38.chrom.sizes'
SNP_TABLE_URL = 'http://hgdownload.soe.ucsc.edu/goldenPath/hg38/database/snp146.txt.gz'

rule all:
    input:
        expand('{sample}.sorted.bam{sufx}', sample=SAMPLES, sufx=['.bai', '']),
        expand('{sample}.bincov.txt', sample=SAMPLES),
        'snp146.bed.gz'

rule download_genome_reference_sequence:
    output: 'reference/hg38.fa.gz'
    shell: 'wget -O {output} {GENOME_FASTA_URL}'

rule download_genome_size_table:
    output: 'reference/hg38.chrom.sizes'
    shell: 'wget -O {output} {GENOME_CHROMSIZES_URL}'

rule build_gsnap_index:
    input: 'reference/hg38.fa.gz'
    output: 'reference/hg38/hg38.genomecomp'
    shell: 'gmap_build --gunzip --circular=chrM,chrMT  \
                -D reference -d hg38 {input}'

rule align_to_genome:
    input: ref='reference/hg38/hg38.genomecomp', fastq='{sample}.fastq.gz'
    output: temp('{sample}.bam')
    threads: 32
    shell: 'gsnap -D reference -d hg38 -m 0.05 -t {threads} -A sam -B4 \
                --gunzip {input.fastq} | \
            samtools view -b -q 3 -o {output} -'

rule sort_alignments:
    input: '{sample}.bam'
    output: '{sample}.sorted.bam'
    threads: 32
    shell: 'samtools sort -@ {threads} -o {output} {input}'

rule index_alignments:
    input: '{sample}.bam'
    output: '{sample}.bam.bai'
    shell: 'samtools index {input}'

rule make_depth_table:
    input: '{sample}.sorted.bam'
    output: '{sample}.bincov.txt'
    shell: 'bedtools makewindows -g reference/hg38.chrom.sizes -w {BINWIDTH} | \
            bedtools coverage -g reference/hg38.chrom.sizes -b {input} -a - \
                -nonamecheck > {output}'

rule download_snp_table:
    output: temp('snp146.txt.gz')
    shell: 'wget -O {output} {SNP_TABLE_URL}'

rule convert_snp_to_bed:
    input: 'snp146.txt.gz'
    output: 'snp146.bed.gz'
    shell: 'zcat {input} | cut -f2-7 | gzip -c - > {output}'

# vim: syntax=snakemake

레퍼런스를 준비해서 매핑하고 빈에 들어간 리드를 센다.

In [38]:
!snakemake -j -- 2>&1 > niptrun.log

리드 개수 데이터 훑어보고 전처리하기

작업 준비가 모두 끝났으니, 이상한 것이 없는지 한 번 살펴보고 쓰기 좋게 손질을 좀 해 둔다.

우선 테이블을 불러들인다. bedtools coverage에서 나오는 포맷이다.

In [33]:
coverage = pd.read_table('bamtori.bincov.txt',
                         names=['chrom', 'start', 'end', 'count', 'nzpos', 'length', 'nzratio'])

염색체 번호 순서대로 정렬할 수 있게 키를 만들어서 정렬한다. 크기가 균일하지 않은 맨 마지막 빈들은 제거한다. Y 염색체도 온전한 빈이 550개가 넘게 나오기 때문에 굳이 궁상맞게 따로 처리할 필요는 없다. 물리적으로 실재하는 염색체 말고 가상의 염색체들(alternative chromosome이나 unplaced scaffolds 등)은 제대로 하자면 복잡하니까 미리 제거한다.

In [35]:
chrom_human_order = lambda x: 'chr{:03d}'.format(int(x[3:])) if x[3:].isdigit() else x

binsize = coverage['length'].value_counts().index[0]

covs = coverage[(coverage['length'] == binsize) & (coverage['chrom'].apply(len) <= 6)].copy()
covs['chrom_sortkey'] = covs['chrom'].apply(chrom_human_order)
covs = covs.sort_values(by=['chrom_sortkey', 'start']).reset_index(drop=True)
covs.head()
Out[35]:
chrom start end count nzpos length nzratio chrom_sortkey
0 chr1 0 100000 175 18882 100000 0.18882 chr001
1 chr1 100000 200000 87 9354 100000 0.09354 chr001
2 chr1 200000 300000 59 6184 100000 0.06184 chr001
3 chr1 300000 400000 34 4553 100000 0.04553 chr001
4 chr1 400000 500000 41 5205 100000 0.05205 chr001

새 컬럼 chrom_sortkeychrom으로 문자열 정렬하면 보통 생각하는 숫자를 기준으로 한 염색체 순서를 맞추기가 어려워서 따로 0을 덧붙여서 만들었다.

컬럼 count에 각 빈에 들어가는 리드 개수가 들어 있다. nzposnzratio는 이번엔 특별히 쓸 일은 없다.

이런 저런 개인적으로 자주 쓰는 라이브러리들을 쓰기 위해 패스를 추가한다. tailseeker 패키지는 GitHub 저장소에서 받을 수 있다.

In [37]:
import sys
sys.path.append('/atp/hyeshik/p/tailseeker')
import readline
from tailseeker import plotutils, stats
from matplotlib import style
style.use('ggplot-hs') # ggplot-hs 없으면 ggplot으로 바꾸세요

matplotlib 스타일 ggplot-hs는 내장 스타일인 ggplot에서 레이블 글꼴 크기, 색깔만 조금 바꾼 것이다. readlineconda와 시스템 기본 libreadline이 버전이 다르다보니 rpy2 모듈을 부를 때 R과 Python이 서로 다른 libreadline을 부르려고 해서 어쩔 수 없이 미리 readline을 불러야만 rpy2를 쓸 수 있게 돼서 우선 이렇게 쓰고 있다.; 다음 업그레이드할 때는 고쳐야겠다.

일단 read count가 대충 어떤 분포로 나오는지 살펴보자. 요즘 생물학에선 바이올린 플랏이 대유행이다. 유행을 좇아 본다.

In [41]:
plt.figure(figsize=(9, 3))
vp = plt.violinplot([np.log2(bins['count'] + 1) for chrom, bins in covs.groupby('chrom')],
                    showextrema=False, showmedians=True, widths=0.8)
plt.setp(vp['bodies'], facecolor='k', alpha=.65)
plt.setp(vp['cmedians'], lw=2)

# chromosome 번호로 X 틱 레이블을 단다.
chromosomes = covs['chrom'].unique()
plt.xticks(np.arange(1, len(chromosomes) + 1),
           [c[3:] for c in chromosomes])

plt.xlabel('chromosome')
plt.ylabel('log2 read count/bin')
Out[41]:
<matplotlib.text.Text at 0x7f717c7155c0>

특별한 처리 없이도 대충 7.3 근처에서 모인다. 그런데! Y염색체에서 대충 중간값이 4.2 정도 되는 한 무리가 나온다. 이 수치를 다른 상염색체와 비교하면 대략 Y 염색체의 비율이 $2^{4.2-7.3}$ 쯤 된다고 할 수 있다. 즉, 11.7% 쯤 되는데, Y염색체는 엄마 DNA에는 없으므로 전적으로 아기 DNA라고 할 수 있다. 상염색체 기준으로 하면 23.4%에 육박하는 수치이므로 보통 아기 DNA가 10-20% 나오는 걸 감안하면 Y염색체가 있다는 걸 확실히 알 수 있다. IGV로 봐도 repeat 지역 여부에 관계없이 고루 퍼져있는 것을 볼 수 있었다. Y는 반복서열이 워낙 많아서 매핑이 어렵고 centromere의 비중이 상대적으로 높기 때문에 아래에 0 근처에 깔리는 빈이 많은 것은 예상할 수 있는 결과다. 아…. 귀염둥이 딸과 노는 꿈은 물거품이.. Y_Y 그래도 아들 둘이면 같이 잘 놀테니까! ^_^

분포만 그리면 재미가 없으니까 outlier가 대충 어떻게 분포하는지 멋지게 점으로 찍어보자. 염색체들을 이어 붙여서 찍을 것이므로 다른 염색체들끼리 구분할 수 있게 무지개색을 준비한다. Y염색체는 칙칙하니까 회색으로 칠한다.

In [44]:
chroms = sorted(covs['chrom_sortkey'].unique())
chromcolors = {chrom: color for chrom, color
               in zip(chroms, plotutils.colormap_lch(len(chroms), end=360))}
chromcolors['chrY'] = '#3d3d3d'

자 이제 찍는다. 각 염색체별로 median은 보기 좋게 바이올린 플랏과 마찬가지로 줄로 긋는다. 염색체 이름은 색깔 맞춰서 중간쯤에 그리는데, 뒤로 가면 글자끼리 겹치니까 위, 아래로 번갈아 표시한다.

In [48]:
fig, ax = plt.subplots(1, 1, figsize=(10, 4))

chromnameY = 8.5
wiggle_y = .2

for i, (chrom, rows) in enumerate(covs.groupby('chrom_sortkey')):
    ax.scatter(rows.index, np.log2(rows['count']),
               edgecolor='none', s=4, alpha=0.2,
               c=chromcolors[chrom])

    center_y = np.median(np.log2(rows['count']).dropna())
    ax.plot([rows.index[0], rows.index[-1]],
            [center_y, center_y],
            c='black')
    
    center_x = np.mean(rows.index)
    ax.annotate(chrom[3:].lstrip('0'),
                (center_x, chromnameY + (wiggle_y * (i % 2) * 2 - wiggle_y)),
                color=chromcolors[chrom], ha='center')

plotutils.apply_dropped_spine(ax)

ax.set_ylabel('Reads per 100k bin (log2)')
ax.set_xlim(-100, 15800*2)
ax.set_ylim(2, 10)
Out[48]:
(2, 10)

centromere 지역에서 집중적으로 빠지거나 튀는 부분이 눈에 띈다. 뭐 크게 문제는 아니다. 특별히 상염색체들끼리는 돋보이는 차이는 없고, 19번과 22번이 다른 것들보다 좀 떨어진다. 19번은 유전자 밀도가 워낙 높아서 GC ratio도 독보적으로 높고, 그 덕에 시퀀싱이 잘 안 되는 것으로 유명한다. 자세히 보지는 않았지만 아마도 그 원인이겠지? ; 22번도 유전자 밀도가 좀 높은 편이다.

사실 이수성(aneuploidy)이 있으면 log2 스케일로 0.15 정도 위아래로 왔다갔다 할 것이다. trisomy가 있다면 22번이 아래로 내려온 정도로 위로 튀면 된다. trisomy가 가장 잘 발생하는 21번 (다운 증후군), 18번 (에드워즈 증후군), 13번 (파타우 증후군) 염색체들은 다행히도 대충 대세를 따른다. 사실 이렇게 한 데이터만 놓고서 분석해서는 정확히 알기는 힘들고, 같은 플랫폼에서 검사한 다른 사람들의 데이터가 많이 모여야 정상치의 범위를 알 수 있는데.. 일단은 데이터가 1개 밖에 없으니 이걸로 대충 보고 만족한다.

GC 비율로 보정하기

사실 웬만한 것은 상용화될 수준까지 가면 간단하지 않은 문제가 돼 버린다. NIPT를 이용한 이수성 검사에서도 다른 사람들의 데이터와 비교하는 방식으로 통계적 유의성을 구하지만, 데이터를 좀 더 균일하게 만드는 여러가지 편향 보정법이 알려져있다.

가장 대표적인 것은 GC 비율 보정인데, 간단하다보니 대부분 업체에서 이 보정은 거의 반드시 채택하고 있다. DNA를 구성하는 AT/GC 중에 염색체에서 GC가 많이 들어있는 지역은 DNA 2가닥끼리 서로 붙어있으려는 힘이 훨씬 강하다. 유전체 전체를 시퀀싱할 때는 DNA를 그냥 쓰면 너무 길어서 다루기가 힘들어서, 초음파를 쏘는 등의 방법으로 DNA를 물리적으로 갈기갈기 찢은 다음에 읽는다. (여기서 쓰는 초음파는 산부인과에서 쓰는 초음파보다 훨씬 세다. 물 속에 담그고 쓰는데도 어쩌다 들으면 귀가 멍멍하다.) 그런데 GC가 많은 지역은 서로 붙어있으려는 힘이 강하다보니 그냥 흔드는 방법으로는 훨씬 덜 찢어져서, 시퀀싱까지 살려서 가기 어렵다. 그래서 GC가 많은 지역은 수가 적게 나올 수 밖에 없다.

그럼 우리도 한 번 해 보자! 먼저 사람 유전체에서 앞에서 계속 쓰던 100kbp 구간별로 GC 비율을 구한다. 이 비율은 굳이 귀찮게 프로그램 만들지 않아도 bedtools느님이 기본제공해 주신다. 하지만 랜덤액세스를 하기 때문에 gzip 압축된 상태로는 못하니까 일단 풀고 한다.

In [73]:
!zcat reference/hg38.fa.gz > reference/hg38.fa
In [57]:
genome_nuc_comp = !bedtools makewindows -g reference/hg38.chrom.sizes -w 100000 | bedtools nuc -fi reference/hg38.fa -bed -
In [58]:
import io
nuccomp = pd.read_table(io.StringIO('\n'.join(genome_nuc_comp))).iloc[:, :5]
nuccomp.columns = ['chrom', 'start', 'end', 'pct_at', 'pct_gc']
nuccomp.head()
Out[58]:
chrom start end pct_at pct_gc
0 chr1 0 100000 0.51793 0.38207
1 chr1 100000 200000 0.54185 0.45815
2 chr1 200000 300000 0.28508 0.19460
3 chr1 300000 400000 0.27479 0.24553
4 chr1 400000 500000 0.59808 0.40192

몇 개 빈에서 AT와 GC를 합쳐도 1이 안 되는 게 있다. 베이스를 모르는 N때문에 그런데, N은 AT인지 GC인지 모르니까 그냥 얘네들은 빼고 다시 계산한다.

In [59]:
nuccomp['pct_gc'] = nuccomp['pct_gc'] / (nuccomp['pct_gc'] + nuccomp['pct_at'])
del nuccomp['pct_at']

이제 앞에서 구해뒀던 빈별 커버리지 데이터와 GC비율을 합치고, 예상했던대로 GC비율과 리드 카운트가 관계가 있는지 보자.

In [66]:
covs_with_gc = pd.merge(covs, nuccomp, left_on=['chrom', 'start', 'end'],
                        right_on=['chrom', 'start', 'end']).dropna()
covs_with_gc.plot(kind='scatter', x='pct_gc', y='count',
                  edgecolor='none', s=5, c='black')
Out[66]:
<matplotlib.axes._subplots.AxesSubplot at 0x7f7178ebc710>

위에 엄청나게 튄 outlier들때문에 뭘 볼 수가 없다. 이게 보이더라도 너무 겹쳐서 밀도가 잘 안 보일 것 같으니, 점의 밀도를 KDE로 색깔로 같이 찍어본다.

In [68]:
from scipy.stats import gaussian_kde
gc_count_mtx = covs_with_gc[['pct_gc', 'count']].as_matrix().T
density = gaussian_kde(gc_count_mtx, bw_method=0.1)(gc_count_mtx)
In [70]:
fig, axes = plt.subplots(1, 2, figsize=(10, 4))

covs_with_gc.plot(kind='scatter', x='pct_gc', y='count', edgecolor='none', s=5,
                  c=density, vmin=0, cmap='jet', ax=axes[0])
covs_with_gc.plot(kind='scatter', x='pct_gc', y='count', edgecolor='none', s=5,
                  c=density, vmin=0, cmap='jet', ax=axes[1])
axes[1].set_ylim(0, 500)
axes[1].set_xlim(0.3, 0.65)

plt.tight_layout()

오! GC 비율이 36-37%정도 되는 빈들이 가장 잘 나오고, 거기서 더 올라가면 떨어진다. 대략 GC 60%까지 가면 리드가 40% 정도 줄어드는 것 같다. AT가 적을 때도 약간 떨어지는 듯한 모양인데, 아마도 조각이 너무 잘아져서 크기로 정제하는 부분에서 없어지지 않을까 싶다. 이제 이걸로 LOESS regression을 하는 것이 생물학에서 보통 쓰는 레퍼토리인데.. GC 비율이 35%에서 42% 쯤 되는 지역의 아랫쪽에 아웃라이어가 꽤 있어서 약간 울퉁불퉁하게 나오기가 쉬울 것 같다. 아웃라이어들이 어떤 놈들인지 찾아봐서 제거하면 좋겠지만, 주업이 아니므로 대충 그냥 마구 잘라서 바로 LOESS에 집어넣는다. 아마도 centromere, heterochromatin이거나 reference와 다른 부분 등이 아닐까 싶다.

In [118]:
from sklearn import svm

gc_count_mtx_training = gc_count_mtx[:, gc_count_mtx[1] > 90]
model = svm.OneClassSVM(nu=0.06, kernel="rbf", gamma=0.5).fit(gc_count_mtx_training.T)
isinlier = model.predict(gc_count_mtx.T) == 1

gc_count_inliers = gc_count_mtx[:, isinlier]
fit_x, fit_y = stats.loess_fit(gc_count_inliers[0], gc_count_inliers[1],
                               np.linspace(0, 1, 200), alpha=.4)

fig, axes = plt.subplots(1, 2, figsize=(10, 4))

covs_with_gc.plot(kind='scatter', x='pct_gc', y='count', edgecolor='none', s=5,
                  c=isinlier, vmin=-.1, vmax=1.1, cmap='RdBu_r', ax=axes[0])
covs_with_gc.plot(kind='scatter', x='pct_gc', y='count', edgecolor='none', s=5,
                  c=isinlier, vmin=-.1, vmax=1.1, cmap='RdBu_r', ax=axes[1])
axes[1].set_ylim(0, 500)
axes[1].set_xlim(0.3, 0.65)
axes[1].plot(fit_x, fit_y, c='magenta', lw=3)
axes[0].plot(fit_x, fit_y, c='magenta', lw=3)

plt.tight_layout()

빨간색이 one class SVM으로 잡은 인라이어. 아래에 깔리는 아웃라이어들은 모델 파라미터에 넣어서 없애려니 금방 안 돼서 그냥 입력값을 미리 잘라서 넣어버렸다 -ㅇ-; 가운데 심지지역의 모서리 부분은 어차피 밀도가 중간에 비해서 많이 낮은 편이라 큰 영향은 안 주리라 보고 그냥 이대로 간다. 하하하하. =3 마젠타색 선이 LOESS 중심선인데, 대충 모양에 맞게 나왔다.

이제 원래 데이터를 LOESS 중심선에 맞춰서 끌어올린 다음에 원래 데이터와 보정한 데이터를 비교해 본다. log가 다루기 편하니, 이제 중심선에 비교해서 얼마나 enrich되었는지 log로 계산해서 쓴다.

In [125]:
gc_inliers_center = np.vstack(stats.loess_fit(gc_count_inliers[0], gc_count_inliers[1],
                                              gc_count_inliers[0], alpha=.4))

gc_logenr = gc_count_inliers.copy()
gc_logenr[1] = np.log2(gc_count_inliers[1] / gc_inliers_center[1])

orig_density = gaussian_kde(gc_count_inliers, bw_method=0.1)(gc_count_inliers)
adj_density = gaussian_kde(gc_logenr, bw_method=0.1)(gc_logenr)
In [129]:
fig, axes = plt.subplots(1, 2, figsize=(10, 4))

axes[0].scatter(gc_count_inliers[0], gc_count_inliers[1], c=orig_density, edgecolor='none', s=5)
axes[0].set_xlabel('pct_gc')
axes[0].set_ylabel('mapped reads')

axes[1].scatter(gc_logenr[0], gc_logenr[1], c=adj_density, edgecolor='none', s=5)
axes[1].set_xlabel('pct_gc')
axes[1].set_ylabel('adjusted mapped reads')
axes[1].set_ylim(-1, 1)

plt.tight_layout()

왼쪽은 GC비율로 보정하기 전, 오른쪽은 보정 후. GC에 맞춰서 파도치던 분포가 올곧게 딱 잡혔다. 후후훗.

이제 원래 작업하던 테이블에 보정된 값을 넣어둔다.

In [148]:
_, expected = stats.loess_fit(gc_count_inliers[0], gc_count_inliers[1],
                              covs_with_gc['pct_gc'], alpha=.4)
covs_w_logenr = covs_with_gc.copy()
covs_w_logenr['log2enrichment'] = np.log2(covs_w_logenr['count'] / expected)
covs_w_logenr = covs_w_logenr[covs_w_logenr['count'] > 0].dropna()

GC보정된 리드 밀도 살펴보기

혹시나 하는 마음에 Y염색체를 다시 살며시 들여다 본다.

In [174]:
chrY_enrichment = covs_w_logenr[covs_w_logenr['chrom'] == 'chrY']['log2enrichment']
xv = np.linspace(-8, 4, 100)
yv = gaussian_kde(chrY_enrichment, bw_method=0.2)(xv)
plt.plot(xv, yv)
plt.xlabel('log2 enrichment')
plt.ylabel('density')
plt.title('chromosome Y')
xv[np.argmax(yv)]
Out[174]:
-3.1515151515151514

대략 -3.15 정도에서 피크가 나온다. $2^{-3.15}=0.1127$ 로.. 여전히 아들이 확실해보인다.

앞에서 그렸던 빈 단위 커버리지를 이번에 GC보정한 log2 enrichment로 그리면 다른 것도 좀 더 깔끔히 나올까?

In [180]:
fig, ax = plt.subplots(1, 1, figsize=(10, 5))

chromnameY = -1.2
wiggle_y = .05

for i, (chrom, rows) in enumerate(covs_w_logenr.groupby('chrom_sortkey')):
    ax.scatter(rows.index, rows['log2enrichment'],
               edgecolor='none', s=4, alpha=0.4,
               c=chromcolors[chrom])

    center_y = np.median(rows['log2enrichment'].dropna())
    ax.plot([rows.index[0], rows.index[-1]],
            [center_y, center_y],
            c='black')
    
    center_x = np.mean(rows.index)
    ax.annotate(chrom[3:].lstrip('0'),
                (center_x, chromnameY + (wiggle_y * (i % 2) * 2 - wiggle_y)),
                color=chromcolors[chrom], ha='center')

plotutils.apply_dropped_spine(ax)

ax.set_ylabel('Reads per 100k bin (log2)')
ax.set_xlim(-100, 31000)
ax.set_ylim(-4, 1)
Out[180]:
(-4, 1)

오! 아까 GC보정 전엔 22번 염색체도 꽤 튀었는데, 이제 괜찮다. 19번 염색체도 훨씬 덜 튀어보인다. X 염색체는 GC보정 전에는 특별히 다른 상염색체와 달라보이지 않았는데, 이제 떨어진 게 확실히 보인다. 아기 DNA에 Y 때문에 X가 한 카피 적게 들어있으므로 대략 5-10% 정도 (log2로 0.07 정도) 빠지는 게 정상이다. 아들이 확실하다.;

그나저나 19번 염색체가 튀는 건 어떤 이유 때문인지 유전자가 없는 intergenic region만 골라서 해 본다던지 좀 더 자세히 들여다 봐야 할 것 같다.

엄마와 아빠 유전형으로 아기 DNA 더 세밀하게 구분하기

여기까지는 그냥 각 염색체별 리드 개수만 열심히 세고, 서열에서 나오는 상세한 정보는 따로 활용하지는 않았다. 시퀀싱의 멋은 그냥 세는 게 아니라 서열 내용을 자세히 분석하는 것 아닌가!
엄마 아빠의 유전형을 알면 읽어낸 서열로 더 자세한 분석이 가능한데, 아기 DNA는 엄마 DNA 반, 아빠 DNA 반을 물려 받으니까, 엄마와 아빠가 다른 유전형을 갖고 있는 부분을 봐서, 아빠와 유전형이 맞는 것을 고르면 아기 DNA의 반에 대해 거의 정확한 비율을 추산할 수 있게 된다.

상용 NIPT에서는 비용 문제 때문에 엄마와 아빠의 유전형 정보를 쓰지는 않지만, 우리 부부는 유전형 정보를 미리 갖고 있었기 때문에, 우리의 유전형 정보로 아기 DNA를 좀 더 세분화 해보기로 했다. 23andMe 데이터를 이런 곳에 처음으로 유용하게 써 볼 줄은 몰랐네..;

미리 준비를 좀 해야 한다. 23andMe는 GRCh37 기준 좌표계를 쓰는데, 우리는 여기서 GRCh38을 쓰고 있어서 좌표 변환이 필요하다. 굳이 번거롭게 변환하지 말고 UCSC Genome Browser가 정리해 놓은 dbSNP 데이터에서 GRCh38 좌표를 바로 얻어온다. dbSNP는 메모리에 띄워놓고 있기에 부담스러울 정도로 방대하니, 23andMe에 있는 것만 추린다.

In [182]:
snptbl = pd.read_table('snp146.bed.gz', compression='gzip',
                       names=['chrom', 'start', 'end', 'name', 'score', 'strand'])
snpcalls_m = pd.read_table('genotype-mother.txt.gz', compression='gzip',
                       comment='#', names=['name', 'chrom', 'position', 'genotype'],
                       low_memory=False)
snpcalls_f = pd.read_table('genotype-father.txt.gz', compression='gzip',
                       comment='#', names=['name', 'chrom', 'position', 'genotype'],
                       low_memory=False)
snpcalls = pd.merge(
    snpcalls_m[['name', 'genotype']], snpcalls_f[['name', 'genotype']],
    how='inner', left_on='name', right_on='name', suffixes=['_mo', '_fa'])
snpmerged = pd.merge(snptbl, snpcalls, how='inner', left_on='name', right_on='name')
snpmerged.to_csv('snpcalls.bed.gz', compression='gzip', header=False, index=False, sep='\t')
del snptbl, snpcalls_m, snpcalls_f, snpcalls

23andMe에서 제공하는 SNP으로 제한한 bed가 준비됐다. 지놈에 정렬된 서열들에서 이 SNP loci에 정렬된 녀석들을 골라온다.

In [184]:
!samtools mpileup -l snpcalls.txt.gz bamtori.sorted.bam | cut -f1,2,4,5,6 | gzip -c - > snpmatched.txt.gz
[mpileup] 1 samples in 1 input files
<mpileup> Set max per-file depth to 8000
In [186]:
serum_snps = pd.read_table('snpmatched.txt.gz', compression='gzip',
                          names=['chrom', 'position', 'reads', 'basecalls', 'basequalities'])
serum_snps['position'] -= 1

parental_snps =  pd.read_table('snpcalls.bed.gz', compression='gzip',
                          names=['chrom', 'start', 'end', 'name', 'score', 'strand',
                                 'mother', 'father'])

snps = pd.merge(parental_snps, serum_snps,
                how='inner', right_on=['chrom', 'position'],
                left_on=['chrom', 'start']).dropna()
snps.head()
Out[186]:
chrom start end name score strand mother father position reads basecalls basequalities
0 chr1 841165 841166 rs12124819 0 + AA AA 841165 1 A @
1 chr1 864626 864627 rs6681049 0 + CT CT 864626 1 T <
2 chr1 989517 989518 rs6665000 0 + AA AA 989517 1 a :
3 chr1 1095184 1095185 rs6687776 0 + CC CC 1095184 1 C-1N 7
4 chr1 1127257 1127258 rs9442373 0 + AC AC 1127257 1 A <

워낙 기본적인 리드 커버리지가 낮기 때문에 SNP별로 뭘 해 보기는 어렵고, 그냥 일치되는 게 있으면 모두 독립적인 SNP인 것처럼 따로 데이터를 취합한다. 엄마와 아빠가 유전형이 같은 곳에서는 아무 정보도 없으니까 엄마와 아빠가 유전형이 다른 곳만 일단 쓴다. 다른 곳은 이렇게 세 가지 유형이 있을 수 있다.

| 번호 | 엄마    | 아빠    |   예     |
|-----|--------|---------|---------|
|  1  | homo   | homo    | GG / AA |
|  2  | homo   | hetero  | GG / AG |
|  3  | hetero | homo    | AG / GG |

2번과 3번의 경우엔 확률 계산이 복잡해지니까 깨끗하게 떨어지는 1번을 우선 쓰자. 양쪽이 모두 homozygote이고 다른 타입이므로 직접적으로 비율을 계산할 수 있다. 아기는 무조건 heterozygote가 된다.

In [193]:
import re

called_homozygote = lambda gt: gt[0] == gt[1] if len(gt) == 2 and gt[0] != '-' else False
def classify_homozygotic_parental_snps(basereads, maternal, paternal,
                                       pat=re.compile('([+-][0-9]+)?([ACGTNacgtn])')):
    r = [0, 0, 0] # maternal matched, paternal matched, matched to neither of them
    for indelmark, base in pat.findall(basereads):
        if indelmark or base in 'nN':
            pass # skip it to simplify the analysis.

        ubase = base.upper()
        if ubase == maternal:
            r[0] += 1
        elif ubase == paternal:
            r[1] += 1
        else:
            r[2] += 1
    return r

snps_mofa_diffs = snps[snps['mother'] != snps['father']].copy()
snps_homo_homo = snps_mofa_diffs[
    snps_mofa_diffs['mother'].apply(called_homozygote) &
    snps_mofa_diffs['father'].apply(called_homozygote)]

snpcounts_homo_homo = pd.DataFrame(
    snps_homo_homo.apply(lambda row:
                         classify_homozygotic_parental_snps(row['basecalls'], row['mother'][0],
                                                            row['father'][0]), axis=1).tolist(),
    index=snps_homo_homo.index,
    columns='mother father mismatched'.split())
In [196]:
snpcounts_homo_homo.sum(axis=0)
Out[196]:
mother        10698
father          743
mismatched      113
dtype: int64

엄마와 아빠가 서로 다른 타입으로 homozygote인 SNP중에 엄마와 같은 것이 10698개, 아빠와 같은 것이 743개다. 아빠에 맞는 것이 아기DNA의 대략 반이므로, 아빠에 맞는 것의 2배를 아기 DNA 비율로 추산할 수 있다.

In [198]:
743/(743+10698)*100*2
Out[198]:
12.98837514203304

아기 DNA의 양은 좀 더 정확한 추산치는 이제 대략 13% 정도다. 오오.

그리고, 여기서 하나 더 알 수 있는 것은, 엄마와 아빠의 대립 SNP 중에 엄마 SNP에 압도적으로 많이 매치되는 것을 보아, 다른 사람의 피나 다른 사람의 샘플이 섞여서 엉뚱하게 나온 게 아니란 것이다.

그렇다면, 엄마에도 매치되지 않고 아빠에도 매치되지 않는 113개는 무엇일까? 거의 대부분 SNP은 A/G나 C/G 같이 두 염기가 대립하지 세 가지가 나오는 것은 드물다. 저것들은 아마도 시퀀싱에러가 아닐까 싶은데.. 확실히 해 보기 위해서 오독했을 가능성이 높은지 확인해 보자. 오독률을 알 수 있는 “리드 퀄리티”는 phred score로 보통 나타낸다. 10점이면 90% 정도 확률로 정확함, 20점이면 99% 확률로 정확함, 30점이면 99.9% 확률로 정확함을 나타낸다. 모두 기계에서 주는 추정값이다.

In [201]:
def iter_snpcall_reads(tbl):
    parse_basecalls = re.compile('([+-][0-9]+)?([ACGTNacgtn])')
    for _, row in tbl.iterrows():
        maternaltype = row['mother'][0]
        paternaltype = row['father'][0]
        basecalls = parse_basecalls.findall(row['basecalls'])
        for (indel, baseread), phredqual in zip(basecalls, row['basequalities']):
            if not indel and baseread not in 'nN':
                yield row['chrom'], row['position'], baseread.upper(), ord(phredqual) - 33, maternaltype, paternaltype

homosnpreads = pd.DataFrame.from_records(iter_snpcall_reads(snps_homo_homo),
                                         columns=['chrom', 'position', 'baseread', 'quality', 'maternal', 'paternal'])
assert (homosnpreads['baseread'] == homosnpreads['maternal']).sum() == 10665
In [208]:
maternal_reads = \
    homosnpreads[(homosnpreads['baseread'] == homosnpreads['maternal'])]
paternal_reads = \
    homosnpreads[(homosnpreads['baseread'] == homosnpreads['paternal'])]
mismatched_reads = \
    homosnpreads[(homosnpreads['baseread'] != homosnpreads['maternal']) &
                 (homosnpreads['baseread'] != homosnpreads['paternal'])]

mat_x, mat_y = plotutils.prepare_cumulative(maternal_reads['quality'])
pat_x, pat_y = plotutils.prepare_cumulative(paternal_reads['quality'])
mismatched_x, mismatched_y = plotutils.prepare_cumulative(mismatched_reads['quality'])

plt.plot(mat_x, mat_y, label='Maternal-matching loci')
plt.plot(pat_x, pat_y, label='Paternal-matching loci')
plt.plot(mismatched_x, mismatched_y, label='Mismatched loci', ls='--', c='magenta')
plt.legend(loc='best', fontsize=12)
Out[208]:
<matplotlib.legend.Legend at 0x7f7031098a20>

오.. 예상했던대로 엄마 것도 아니고 아빠 것도 아닌 SNP들은 리드 퀄리티가 현저히 떨어진다. 중간값이 대략 20쯤 된다.

자 그렇다면 염색체별로 차이가 있을 수 있으니 아빠:엄마 비율을 자세히 살펴보자.

In [211]:
r = []
for chrom, grp in homosnpreads.groupby('chrom'):
    if len(chrom) > 5:
        continue
    maternal_reads =  grp[(grp['baseread'] == grp['maternal'])]
    paternal_reads = grp[(grp['baseread'] == grp['paternal'])]
    mismatched_reads = grp[(grp['baseread'] != grp['maternal']) &
                           (grp['baseread'] != grp['paternal'])]
    r.append((chrom, len(maternal_reads), len(paternal_reads), len(mismatched_reads)))
    
homosnpmatches = pd.DataFrame(r, columns=['chrom', 'maternal', 'paternal', 'mismatched'])
homosnpmatches['p2m_ratio'] = homosnpmatches['paternal'] / (homosnpmatches['maternal'] + homosnpmatches['paternal'])
homosnpmatches.sort_values(by='p2m_ratio')
Out[211]:
chrom maternal paternal mismatched p2m_ratio
22 chrX 2 0 0 0.000000
14 chr22 186 7 0 0.036269
6 chr15 343 16 6 0.044568
19 chr7 602 29 2 0.045959
16 chr4 606 31 2 0.048666
4 chr13 390 23 2 0.055690
10 chr19 164 10 0 0.057471
15 chr3 758 48 3 0.059553
11 chr2 931 60 2 0.060545
20 chr8 620 40 2 0.060606
3 chr12 538 35 3 0.061082
5 chr14 290 19 1 0.061489
0 chr1 851 57 3 0.062775
1 chr10 615 44 11 0.066768
18 chr6 652 49 4 0.069900
21 chr9 537 43 1 0.074138
12 chr20 244 20 2 0.075758
2 chr11 571 47 4 0.076052
9 chr18 404 34 0 0.077626
8 chr17 229 20 2 0.080321
17 chr5 664 59 3 0.081604
7 chr16 278 25 2 0.082508
13 chr21 157 20 1 0.112994

X 염색체는 아빠가 1쪽 밖에 없어서 그런지 여기서 비교할 수 있는 것 자체가 적게 나왔다. (총 2개) 대부분 전체 유전체 평균 수치인 6.5% 근처로 나오는데, 22번 염색체가 유난히 적고, 21번 염색체가 유난히 많다. 21번 염색체는 수가 20개 밖에 되지 않지만, 아빠 SNP이 다른 염색체의 거의 2배가 나왔다. 이항분포로 보면,

In [222]:
%load_ext rpy2.ipython
%R pbinom(20, 157+20, 0.065, lower.tail=FALSE)
The rpy2.ipython extension is already loaded. To reload it, use:
  %reload_ext rpy2.ipython
Out[222]:
array([ 0.00574275])

흔히 나올 만한 숫자는 아니다. 21번 염색체가 더 많이 들어간 걸까? 좀 더 숫자를 늘리기 위해서 엄마는 homozygote, 아빠는 heterozygote인 경우도 해 보자.

In [226]:
snps_homo_hetero = snps_mofa_diffs[
    snps_mofa_diffs['mother'].apply(called_homozygote) &
    ~snps_mofa_diffs['father'].apply(called_homozygote)]

def iter_snpcall_reads_father_hetero(tbl):
    parse_basecalls = re.compile('([+-][0-9]+)?([ACGTNacgtn])')
    for _, row in tbl.iterrows():
        maternaltype = row['mother'][0]
        paternaltype = ''.join(set(row['father']) - set(maternaltype))
        if maternaltype == '-' or paternaltype == '-':
            continue
        basecalls = parse_basecalls.findall(row['basecalls'])
        for (indel, baseread), phredqual in zip(basecalls, row['basequalities']):
            if not indel and baseread not in 'nN':
                yield row['chrom'], row['position'], baseread.upper(), ord(phredqual) - 33, maternaltype, paternaltype

snpreads_fhetero = pd.DataFrame.from_records(iter_snpcall_reads_father_hetero(snps_homo_hetero),
                                         columns=['chrom', 'position', 'baseread', 'quality', 'maternal', 'paternal'])
In [227]:
r = []
for chrom, grp in snpreads_fhetero.groupby('chrom'):
    if len(chrom) > 5:
        continue
    maternal_reads =  grp[(grp['baseread'] == grp['maternal'])]
    paternal_reads = grp[(grp['baseread'] == grp['paternal'])]
    mismatched_reads = grp[(grp['baseread'] != grp['maternal']) &
                           (grp['baseread'] != grp['paternal'])]
    r.append((chrom, len(maternal_reads), len(paternal_reads), len(mismatched_reads)))
    
snpmatches_fhetero = pd.DataFrame(r, columns=['chrom', 'maternal', 'paternal', 'mismatched'])
snpmatches_fhetero['p2m_ratio'] = snpmatches_fhetero['paternal'] / (snpmatches_fhetero['maternal'] + snpmatches_fhetero['paternal'])
snpmatches_fhetero.sort_values(by='p2m_ratio')
Out[227]:
chrom maternal paternal mismatched p2m_ratio
22 chrX 4258 3 52 0.000704
9 chr18 1026 26 3 0.024715
17 chr5 2082 60 11 0.028011
21 chr9 1626 48 6 0.028674
4 chr13 1341 43 4 0.031069
0 chr1 2912 95 18 0.031593
1 chr10 2033 68 24 0.032366
15 chr3 2456 85 19 0.033451
5 chr14 1172 41 5 0.033800
11 chr2 2745 97 13 0.034131
18 chr6 2105 76 15 0.034846
12 chr20 871 32 4 0.035437
19 chr7 1912 71 10 0.035804
20 chr8 1853 75 11 0.038900
3 chr12 1660 68 7 0.039352
16 chr4 2143 88 9 0.039444
10 chr19 509 21 5 0.039623
7 chr16 966 40 9 0.039761
14 chr22 408 17 5 0.040000
2 chr11 1764 75 14 0.040783
8 chr17 877 40 8 0.043621
6 chr15 1137 52 12 0.043734
13 chr21 427 22 2 0.048998

오. 여기서 본 SNP 중에 X염색체에 있는 4000개가 넘는 SNP가 거의 몽땅 엄마에 매치된다. 역시 아들이다. ㅎㅎㅎ; (딸이면 아빠에게서도 X염색체를 물려받아서, 이 비율이 다른 상염색체와 거의 같게 나와야 한다.)

자 여기서도 21번이 다른 것들보다 유난히 높다. 아빠가 hetero이고 엄마가 homo이기 떄문에, 아기 DNA의 4분의 1은 아빠와 엄마가 다른 SNP loci에서 엄마와 맞지 않고 아빠와 맞게 된다. 따라서, 이 비율에 2배 하면 아빠한테서 물려받은 아기 DNA 비율을 추산할 수 있다.

21번 염색체의 수치인 4.9%는 아빠한테서 9.8% 정도를 물려받았다는 것이 된다. 앞에서 계산한 아기 DNA의 비율이 13% 정도 되기 때문에, 21번 염색체의 경우 거의 75%를 아빠한테서 물려받은 게 된다. 리드 수를 이용한 파이프라인에서는 trisomy 21 가능성은 거의 없다고 했다. 앞에서 homo-homo SNP들의 경우엔 다른 염색체들의 2배 가까이 아빠에 매치되었으니까, 종합하면 21번 염색체 두 카피를 모두 아빠한테서 받고 엄마한테서는 받지 않은 paternal uniparental disomy (patUPD)의 가능성도 무시할 수 없다.

하지만 논문을 열심히 뒤져보니, 21번 염색체의 patUPD가 워낙 드문 현상인데다, 21번 염색체는 다행히도 아직 UPD에 의한 표현형(즉 눈에 띄는 질병 또는 특징)이 보고된 것이 없다. 완전 정상이라고 보고된 사례만 몇 있다. 그래서 21번 염색체 patUPD도 가능성이 없는 것은 아니지만, 기저율 덕에 실질적인 가능성은 낮을 것 같고. 실제로 patUPD라고 하더라도 특별히 문제있을 것 같지 않다.

SNP 데이터로 뭘 좀 더 해보면 좋을텐데, 아내와 내가 다른 유전형이면서, 재미있는 정보가 알려진 것이 있고, 리드가 그래도 2개는 되는 걸 찾아보니 하나도 없었다. 커버리지가 이수성을 보는 것에 최적화되어 있다보니 어쩔 수 없는 것 같다. 몇 년 지나서 시퀀싱이 아주 싸지게 되면 전가족 시퀀싱을 한 번 해 보면 재미있겠다~

결론

여러가지 재미있게 분석을 해서 얻은 결론은

  • 아들임이 확실하다는 독립적인 증거를 셋이나 찾음. (Y염색체 비율, X염색체 비율 감소, X염색체 SNP)
  • 다른 사람 샘플과 뒤바뀌었을 가능성은 거의 없음.
  • 21번 염색체 patUPD가 있을 수도 있지만 가능성은 매우 낮고, 있더라도 아기에 큰 영향은 없을 듯하다.
  • 커버리지 0.3X는 생각보다 더 리드 구경하기가 힘들다. 대부분 SNP은 한 번도 읽히지 않고, 읽힌 것도 대부분 딱 1번 읽혀서, SNP단위로 뭘 해 보기는 어렵다.
  • 이수성만 검사하는 경우라면 커버리지를 훨씬 더 내려서 가격을 내릴 수 있을 것 같다.

말로만 듣던 NIPT 데이터를 대충이나마 직접 한 번 다뤄보니 감촉이 약간 느껴지는 것 같기도 하고 어떤 것인지 이해가 좀 됐다. 아직 눈으로 보지도 못한 둘째를 데이터로 미리 만나보니 그것도 기분이 참 좋았다. ^^; 셋째가 나오기 전에 이번 데이터로 뭔가 새로운 방법이나 개선점을 하나 만들 수 있으면 좋을텐데~

계산생물학자를 위한 맥 키보드 레이아웃

생물학 텍스트를 쓸 때 자주 쓰는 부호 몇 개가 키보드에 없다. 맥 U.S. 키보드 배치에서 옵션 키 조합으로 몇 가지 쓸 수 있다. ˚C의 ˚는 Opt-K로, µl의 µ는 Opt-M로 누를 수 있어서 편리하다. 하지만 기본 바인딩으로 충분하지 않아서 항상 입력기 문자표를 띄워두고 클릭해서 넣어야 하는데, 여간 불편한 것이 아니다.

3′ end, 5′ end 할 때의 ′ (프라임)은 한 문장에도 여러 번 쓰기도 하고, 그리스 알파벳 α, β, γ 등도 단백질 이름이나 분자구조 명칭에서 자주 쓰지만 단축키가 없다. 계산생물학 텍스트는 좀 더 문자표에 의존하게 된다. TeX을 쓸 정도로 수식, 기호 조판이 필요하지는 않지만 (저널들도 TeX을 별로 좋아하지 않고), 그래도 간단한 산술/비교 연산자 (×, ·, ≪, ≫), 논리 및 집합 연산자 (¬, ∩, ∪, ∧, ∨ 등)를 자주 쓰게 되는 경우가 많다.

오늘도 열심히 문자표를 띄워놓고 프라임을 입력하다가, “아 이제 더 이상 안 되겠다.” 싶어 열심히 검색해서 키 바인딩을 만들었다. 자주 쓰는 것들 대부분을 포함하려고 우선 생각나는 것은 다 넣었지만 빠진 것이 있을 수도 있다.

Layout-Opt
옵션 조합
Layout-OptShift
옵션+시프트 조합

이렇게 하면 설치할 수 있다.

  1. 이 파일을 받아서 압축을 푼다. MacKeyCompBiol-r1.zip
  2. 풀린 파일 중에 English (Computational Biology).bundle을 ~/Library/Keyboard Layouts/ 에 넣는다. 이 디렉토리가 없으면 만들어서 넣는다.
  3. 로그아웃 하고 다시 로그인하거나 컴퓨터를 껐다가 다시 켠다.
  4. System Preferences – Keyboard – Input Sources 에 들어가서 +를 누르고 English (Computational Biology)를 추가한다.
  5. 화면 오른쪽 위의 메뉴바에서 새로 추가한 입력기를 선택한다.

이 방법으로 추가하면 원래 쓰던 영문 키보드 레이아웃과 한글에 더해서 입력기가 3개가 돼서 입력기 선택이 상당히 불편해지는데, 다음과 같이 하면 영문 키보드를 없앨 수 있다. (GUI에서 없애기가 힘들다.)

  1. 로그아웃하고 다시 로그인하거나 컴퓨터를 껐다가 다시 켠다.
  2. 터미널을 띄우고, 다음 명령어로 입력기 설정을 고칠 수 있게 변환한다.
  3. 11번 라인 근처에 AppleEnabledInputSources 딕셔너리들이 주욱 있는데, U.S. English 또는 지우고 싶은 키보드를 없앤다. <dict>부터 </dict>까지 지워야한다.
  4. 로그아웃하고 다시 로그인하거나 컴퓨터를 껐다가 다시 켠다.
  5. 위 키보드 레이아웃을 보고 뿌듯하게 5′ 을 눌러 본다.

레이아웃에 개선할 점이 있는 분들은 알려주시면 다음 버전에 반영하겠습니다! ?

모든 언어에는 긍정적 단어가 부정적 단어보다 많은가?

대략 100년 전 미국에서 “폴리애나”라는 동화가 출간됐다. 주인공인 “폴리애나”는 오랫동안 미국 문화에서 초낙천적인 성격의 대명사로 사랑받았다고 한다. 고아가 되어 이모 집에서 살게 된 폴리애나가 “모든 일에는 좋은 면이 있으니 그것을 찾아보자”하는 태도로 이모의 구박을 이겨내고 행복하게 살면서 주변 사람들한테도 그런 태도를 퍼뜨린다는 이야기다.

1969년 미국의 두 심리학자는 여러 문화권 언어들을 비교해 보고, 어떤 언어이건 긍정적인 뜻인 단어가 종류도 많고, 더 자주 사용되고, 다양한 문맥에서 사용된다고 “폴리애나 가설”을 세웠다. 이 가설을 검증하는 연구는 많이 있어서 특별히 새로울 것은 없지만, 며칠 전 새로 나온 이 논문에는 재미있는 점이 두 개 있다.

첫 번째, 10가지 언어에 대한 말뭉치에서 뽑은 단어를 모국어 사용자들에게 알바로 긍정-부정 지표를 매기게 시켜서 그 정도를 비교해 보니까 스페인어가 긍정적 단어가 가장 많이 사용되고, 중국어가 가장 덜 긍정적이었다. (한국어도 중국어와 비슷하다.) — 다만 이 부분은 말뭉치가 너무 작고 텍스트의 종류도 달라서 그냥 재미로 해 본 정도로 볼 수 있겠다.

두 번째, 이 논문 figure 4가 아주 멋진데, 소설 3권 (모비딕, 죄와 벌, 몬테크리스토 백작)을 놓고 소설의 진행 순서에 따라서 긍정적 단어와 부정적 단어의 빈도 변화를 딱 그렸더니만, 기승전결과 갈등구조가 똭! 하고 보이는 것! 이런 분석을 이용하면 스토리 구조가 비슷한 책을 보여준다거나, 하이라이트 부분을 자동으로 찍어서 그 부분을 보여줄 수도 있겠다. 다른 책도 찾아볼 수 있게 사이트도 만들었다. 해리포터를 보니까 그럴 듯 하다. ㅎㅎ;

* 관련 논문: Dodds et al. (2015) Human language reveals a universal positivity bias. Proc. Natl. Acad. Sci. U. S. A. doi:10.1073/pnas.1411678112

터미널엔 롯데리아, 동네에 맥도날드, 강남에 버거킹..?

2015년 1월 27일 개정 1판 안내

처음 공개된 분석과 글에 있었던 여러 오류를 바로 잡았습니다. 다음 문제점들을 수정하였습니다. 코멘트 주신 분들께 감사드립니다. ^^

  1. 사각형 cartogram 지역 재배치
    1. 서울시 노원구, 도봉구 위치 바로 잡음. (오유 자리****범 님)
    2. 강원도 춘천, 홍천 위치 바로 잡음. (오유 모* 님)
    3. 서울시 구로구, 금천구 위치 바로 잡음.
  2. 1월 26일 기준 데이터로 업데이트. (맥도날드와 롯데리아 매장이 많이 추가되었습니다.)
  3. KFC 석촌역점이 처리 도중에 누락됐던 문제 바로 잡음.
  4. 상관계수 분석을 잘못 뭉뚱그려 설명한 부분을 수정하여 구체적인 것으로 교체. (트위터 for*********er 님)
  5. 인구밀도-버거지수 스캐터 차트에서 분포가 좀 더 잘 보이게 LOESS 트렌드선 추가.
  6. 그림에서 일관성이 떨어지거나 부정확한 용어들이 섞여있던 것 수정.

그리고, 한 가지 덧붙이고 싶은 말은, 이 글은 진지한 어조와 형식으로 되어 있긴 하지만, 유머 또는 광고(IPython/pandas) 분류를 의도하고 쓴 글입니다. 결과에 대해 경영/사회 측면에서 심각하게 고민하실 정도의 정밀한 연구가 아니니, 구체적인 것은 해당 분야 전문가들이 제대로 분석하신 뒤에 보셔야 합니다. ^_^;;


읍내 버스터미널에 가면 롯데리아가 언제나 반갑게 맞이한다. 그렇게 보기 쉽던 롯데리아가 희한하게도 강남이나 신촌 같은 곳에서는 뜻밖에 찾아보기 힘들다. 작년 가을, 트위터 @RioterOfMiku 님은 이 현상을 한 눈에 이해되는 아주 강렬한 도시 발전 지수로 정리했다.

https://twitter.com/godtsune_miku/status/513648274406789120

누구든 대략 듣자마자 “그래? 그런 것 같네~” 정도론 동의하게 되는 간단하면서도 정보가 있는 지수인데. 어느 날 연구실에서 오랜만에 햄버거를 시켜 먹다가 문득 생각나서 “진짜 그럴까!?” 생각에 좀 더 구석구석 뒤져보기로 했다.

구체적인 것은 분석 과정을 담은 IPython 노트북을 올려두었으니 관심있는 분들은 여기를 좀 더 보도록 합시다. ^_^

전국 시군구 버거지수

우선, 전국 시군구에 대한 버거지수를 진짜로 계산해 본 적은 없으니 실제로 계산해 보자. 각각 롯데리아, 버거킹, 맥도날드, KFC 홈페이지에서 2015년 1월 26일 기준 데이터를 긁어서 계산했다.

정말 느끼는 대로 강남, 서초는 하늘을 찌르는 “버거지수”를 보여준다. 즉, 강남과 서초에는 롯데리아에 비해 버거킹, 맥도날드, KFC가 훨씬 많다. 다른 지역들은 이 비율이 대략 0.5를 넘는 곳도 드문 와중에, 4배를 넘어선다. 다른 지역에서도 주로 “시내”권인 중구 지역이 버거지수가 좀 더 높다. 하나 의외인 곳은 강원도 홍천군이 2.0점! 여기는 강남, 서초 다음으로 전국에서 3번째로 높은데… 매장 목록을 보면 대명비발디파크에 있는 버거킹 2개 때문이다.; 강원도 정선군은 색은 상당히 짙은 게 높아 보이지만, 롯데리아와 KFC가 1개 씩으로 롯데리아 수가 적어서 지수가 높게 나온 영향이다.

그렇다면 왜 이런 현상이 일어날까? 롯데리아가 시골을 좋아하는 걸까, 아니면 버거킹 등이 도심을 유난히 좋아하는 걸까? 아니면 또 다른 이유가 있을까? 좀 더 자세히 파 보자.

각 브랜드 별 매장 분포

우선 어느 버거 브랜드 매장이 어디에 있는지 수를 봐야한다.

역시 롯데리아 매장 수가 압도적으로 많다. 버거킹, KFC, 맥도날드 다 합쳐도 롯데리아 매장 수에 미치지 못한다. 단지 롯데리아 매장 수가 많기 때문에 좀 더 인구가 적은 지역까지 커버할 수 있다는 가설이 설득력이 생기는 차이다. 각 브랜드별 매장 분포를 좀 더 자세히 지역별로 살펴보자.

역시.. 롯데리아 색은 아주 예쁘게 고르게 분포한 반면에, 버거킹+맥도날드+KFC (줄여서 BMK) 모두 합쳐도 매장이 하나도 없는 곳이  많다. 롯데리아는 서울의 한 구보다 많은 곳이 지방에도 많은 반면에, BMK는 광역시 이상 도시보다 매장이 많은 지방이 거의 없다. 광역시 내에서도 다른 구들에 비해 “중구”에 쏠리는 현상이 BMK가 롯데리아보다 훨씬 뚜렷하다.

브랜드별로 각각 어느 브랜드가 어느 것과 비슷한지 좀 더 자세히 살펴보자.

피어슨 연관성 계수로 봤을 때, 의외의 결과! 맥도날드는 KFC나 버거킹보다 롯데리아에 더 가깝다. KFC와 버거킹은 서로 분포가 비슷하다. 맥도날드가 완전 도심보다 서울 안에서도 주로 주택가 상가에 많이 분포하는 걸 생각해보면 그럴 수도 있겠다 싶다. 버거지수 분자에서 맥도날드를 빼는게 더 원래 의도에 맞을 것 같다.

롯데리아 매장은 좀 더 인구가 적은 곳에서도 열 수 있는 것일까?

인구밀도와 버거지수의 관계

롯데리아가 인구밀도가 낮아도 먹고 살 수 있다면, 버거지수와 인구밀도가 어느 정도 상관관계가 있을 것이다. 비교해보면 진짜 그렇다! (다음 그림)

인구밀도가 높을 수록 (오른쪽으로 갈 수록), 버거지수가 높다. 켄달의 타오값도 0.618이나 된다. +_+ 원인은 분모 (롯데리아)에 있을 수도 있고 분자 (BMK)에 있을 수도 있겠다. 어느 것이 더 원인일까?

인구수에 정확하게 비례하는 모델로 매장을 흩어놓았을 때에 비해 얼마나 실제 매장이 많은지 (Y축)을 보면, 아주 강한 상관관계가 나오지는 않지만, 롯데리아는 어느 정도 이상 되면 인구밀도가 높을 수록 매장 수가 약간 줄어드는 경향이 있다. BMK의 경우에는 너무 흩어져 있어서 확실히 뭐라고 할 수는 없지만, 인구밀도가 낮은 지역에서 매장이 아예 없는 점이 상당히 많다. 전체 매장 수 자체가 적다보니 인구가 적은 지역을 커버하지 못하는 것으로도 볼 수 있겠다. 롯데리아가 인구밀도가 높을 수록 약간 줄어드는 것은 매장 간의 최소 거리를 고려하면 포화된 것으로도 해석할 수 있는데, 정확하게 보려면 좀 더 실제 데이터를 보고, 유동인구, 세대별 인구 같은 걸 봐야겠지만… 오바 같아서 그만 -ㅁ-;;

매장수 차이의 효과

마지막으로, 그냥 BMK가 각각 매장수가 적다보니 인구밀도가 적은 지역까지 진출하지 못한 효과가 버거지수를 만들 수 있는지 보기 위해, 각 시군구를 분할하면서 인구가 가장 많은 곳에 새로 매장을 만드는 식으로 시뮬레이션 했을 때 버거지수가 어떻게 나올지 확인해 보자. (자세한 것은 코드 확인.)

시뮬레이션에서 인구가 적어서 버거지수가 낮은 것으로 나온 곳은 실제 버거지수도 낮은 경향이 있었다. 하지만, 높게 예측된 곳 중에서는 인구가 큰 정보를 주지는 못했다. 특이한 점들을 몇 군데 보면, 다음 지역은 인구만 이용한 시뮬레이션에서 버거지수가 낮게 예측됐지만, 실제로는 매우 높은 점수가 나왔다. 과천시 빼고는 모두 인구에 비해 유동인구가 아주 많은 지역들이다.

시군구 롯데리아 버거킹 맥도날드 KFC
강원도 홍천군 1 2 0 0
인천광역시 중구 6 2 5 2
부산광역시 중구 4 1 1 3
경기도 과천시 3 1 1 1
강원도 정선군 1 0 0 1
대구광역시 중구 7 1 2 4

다음 지역들은 인구를 기반으로 한 시뮬레이션에서는 아주 낮지는 않은 버거지수가 나왔지만, 실제 버거지수는 매우 낮은 지역이다. BMK의 입지 조건에 맞는 곳이 없으려나..? 이유는 좀 더 살펴봐야겠다. -O-

시군구 롯데리아 버거킹 맥도날드 KFC
충청남도 보령시 3 0 0 0
경상북도 영천시 2 0 0 0
대구광역시 달성군 6 0 0 0

결론

롯데리아는 인구밀도가 많은 곳에서 오히려 인구 대비 매장수가 약간 적은 경향이 약간 있었고, BMK의 매장수가 전국을 덮을 수 있을 정도로 많지 않은 것이 또한 버거지수를 가르는 요인이었다. 다만, 이 글에서는 연관성에 집중했을 뿐, 이 현상의 원인에 대한 구체적인 인과관계를 알 수는 없으므로 해석은 좀 더 조심스러워야 한다. 또한, 시군구경계로 아주 단순화했고 유동인구나 인구 구조 등의 특징을 충분히 고려하지 못했기 때문에, 상권이나 구체적 인구 자료를 더 살펴봐야 할 것 같다. 이것은 소스와 데이터를 공개했으니 후행연구로 누군가… ㅎㅎ;

TAIL-seq 두 논문 뒷 이야기

올해는 어떻게 하던 일이 잘 풀려서 (작년에 리젝을 하도 많이 당해서 쭉 밀려서) 논문을 두 편 냈다. 하나는 poly(A) 꼬리 길이를 재는 방법을 만든 논문이고, 하나는 새로 만든 그 방법을 연구실에서 관심 많았던 주제에 적용한 논문이다. 많은 분들이 “왜 얘네들은 하던 miRNA말고 갑자기 뜬금없이 poly(A) 꼬리 길이를 쟀을까?” 궁금해 하시기도 하고, 멋쟁이 논읽남 박사님의 추천도 있고 해서, 독자들이 궁금해 할 (지도 모르는) 것들을 혼자 묻고 답하고 해 본다. ㅎ;

Poly(A) 꼬리 길이는 도대체 왜 재기 시작한 거냐?

제대로 있었던 그대로 설명하자면 얘기가 꽤 길다. 원래, 우리 랩은 miRNA의 전구체인 pre-miRNA와 pri-miRNA를 만지는 단백질들을 주로 연구해 왔다. 그러다가 2008-9년에 pre-miRNA의 3′끝에 U를 붙이는 단백질인 TUT4를 발견했고, 2012년엔 그렇게 U가 붙으면 생성과정이 더 잘 돌아가는 일부 pre-miRNA들을 발견했다. pre-miRNA만 열심히 들고 파던 중, RBP에 눈이 달려서 붙일 놈 사이즈를 대충 보고 덤비는 것도 아니고, 분명히 다른 RNA도 어떤 건 3′만 나왔다 하면 냅다 붙이는 현상이 분명히 있을 것이라고 얘기가 나왔다.

그러던 중, 2012년 U를 붙일 pre-miRNA를 물색해 주는 역할을 하는 LIN28A라는 단백질이 mRNA에 잘 붙는다는 논문을 내면서, “마음씨 착한” 리뷰어 중 한 명이 “논문 내용은 잘 모르겠고, LIN28A하면 TUT4, TUT4하면 U, 그러면 mRNA에도 U 붙이는 거 아니야?”라고 창의적 드립을 쳐서, “님, 그건 좀 너무 나가신 것 같지만, 너무 좋은 코멘트이니 감사합니다. 그거 하다가는 이 논문 안 끝나니 다음으로.. ㅈㅅ”  답장을 쓰고 일단 논문은 나왔다.

LIN28A가 붙는 곳이 3′ 끝 근처도 아니고 mRNA 전역에 흩어져 있다보니 당시엔 아주 진지하게 생각해 보지는 않았다. 그런데 지저분한 세포 안 세상엔 LIN28A 아니라도 뭐라도 순진한 poly(A) 끝에 뭔가 붙여줄 녀석이 있을 거라는 생각이 계속 들었고, 2013년 2월. 결국 이번 연구를 같이 했던 임재철군이 본격적으로 poly(A)뒤에 있는 것을 시퀀싱해서 알아내자는 프로젝트를 시작했다.

나는 한편 2012년 논문을 끝내고 다음 일은 뭐할까~ 물색하던 중 당시에 논문이 몇 개 연속으로 나왔던 적혈구의 일주기(circadian rhythm) 시스템에 완전 꽂혀서 한참 보다가, RNA조절쟁이들의 원수! 전사조절이 없으니 뒤벼보나마나 poly(A) 길이로 translation 조절이 엄청 중요한 것이 몇 개는 있을 것이다 하는 가설을 세우고서는, 그 가설과 사랑에 빠져버리고 말았다. ☞☜.. 그래서, 당시에 거의 없는 것이나 마찬가지였던 poly(A) 대량 길이재기를 어떻게든 해 보려고, 몇 가지 실험을 디자인했는데… 당장 싱싱한 적혈구를 대량으로 구하기도 힘들고, 일주기 실험하다가는 폐인을 면치 못한다는 여러 문제로 일단 다른 하던 일을 하면서 마침 마찬가지로 poly(A)가 통째로 시퀀싱 라이브러리에 들어있는 재철이의 라이브러리가 내용이 너무 궁금해서 분석을 도와주면서 엉뚱했던 poly(A) 길이 재기가 시작하게 됐다.

Poly(A) 꼬리 길이 재는 건 그냥 seq.count(‘A’)하면 되는 거 아닌가?

이게 우리 첫 논문의 핵심인데, 454던 일루미나던 증폭과정이 들어가 있는 모든 2세대 시퀀서들은 똑같은 염기가 반복되는 패턴에 매우 약하다. 454보다는 낫다고는 하지만, 일루미나도 20bp만 넘어가도 제 정신을 못 차린다. phasing과 pre-phasing이라고 부르는 문제와 polymerase jumping/skipping이라고 부르는 문제가 겹쳐서 그런 것인데 자세한 것은 링크 참조. 그래서 30nt만 해도 오차가 5nt 정도 나고, 60nt정도 되면 40-60nt이상 오차가 나기 시작해서, 150nt 정도 되면 아예 측정이 불가능하다. (시퀀스 가지고 아무리 좋은 오류 모델을 세워도 오차가 200nt 이상 난다.)

마침, 재철이와 나는, 많은 생물정보학 커뮤니티에서 “다들 필수라지만 현실에 있긴 있는거야?”라고 의심하는 “실험 설계 과정에서부터 공동 설계”를 진짜로 실천한 덕에, 어떻게 해석해야 할 지도 모르게 이상하게 나온 첫 데이터를 보고 바로 방향을 제대로 잡을 수 있었다. 이후 단계로 진행할 수 있었던 가장 중요했던 첫 실험 설계 포인트 몇 가지는 다음과 같다.

  • Poly(A)길이를 진짜로 잘 잴 수 있는지 테스트해 보려고 긴 poly(A)를 화학적으로 합성해서 넣어서 같이 시퀀싱했다. 그 덕에 얼마나 진짜로 안 되는 건지 알 수 있고, 다른 대안 알고리즘들을 시험해 볼 수 있었다.
  • Homopolymer가 시퀀싱이 잘 안 된다는 사실을 미리 어느 정도는 소문을 들어 알고 있었기에, basecalling을 다른 프로그램으로 해 보려고 형광신호를 정량한 원데이터인 CIF파일들을 저장해두어서 다행히도 나중에 자세히 들여다 볼 수 있었다. (CIF는 기본 옵션에서는 자동으로 시퀀싱 도중에 지워지게 되어있고, MiSeq에서는 심지어 공식 GUI에 없는 은밀한 방법을 써야 저장할 수 있다.)
  • 여러 필터 옵션을 끄고 시퀀싱과 분석을 했다. poly(A)가 껴 있으면 read quality가 낮다보니 basecalling 과정에서 QC하다가 빠져서 FASTQ에서 이미 거의 대부분 없어져 있고, 그나마 남은 것들도 보통 많이 적용하는 quality filter에서 다 없어져버린다. 우리는 현실을 있는 그대로 한 번 열심히 봐 보자! 하고 필터를 모두 끄고 본 덕에 poly(A)가 나오기는 나왔다는 사실을 알게 돼서 거기서 힌트를 얻어서 이후 분석을 시작할 수 있게 됐다.

일단 신호를 본 뒤로는 전형적인 GMHMM쓰면 쉽게 해결될 만한 모양이라, 대략 이틀 만에 알고리즘 구성과 테스트가 끝나버렸다.  하지만 더 간단하고 멋진 방법이 있을 것 같다는 생각에 다른 것도 해 보다가 그 이후 3주일을 뻘짓했다. ;ㅁ;

첫 번째 논문은 뒤가 왠지 허전한데 무슨 일 있었나?

알고리즘도 거의 완성하고 한창 재미있게 논문에 살을 붙이던 2013년 6월, 스위스 다보스에서 열렸던 RNA Society Meeting 2013에서 David Bartel 랩에서 poly(A) 길이를 시퀀싱으로 재는 방법을 발표했다. 두 번째 리비전을 얼마 전에 받았다는 걸 봐서는 accept가 머지 않은터라 우리는 완전 비상상황이 돼 버렸다. 사실 Bartel랩에서는 2012년에 이미 poly(A) 길이를 재는 걸 시도했었는데 본문에서 2페이지 가까이 되는 내용을 쓰고서도 초록에는 한 글자도 언급이 안 될 정도로 poly(A) 길이 재는 것 자체는 완전 망한터라 그냥 안 하려나보다 하고 있었는데, 오랫동안 쭉 진행하고 있었다고 했다. 그래서…. 열심히 달려서 우선 기술 자체에만 집중해서 서둘러서 논문을 준비해 9월에 첫 투고를 했다. 그 뒤로 1달 주기로 3연속 출판 거절ㅋ (어딘지는ㅋㅋ). 그러다가 결국 공개된 날짜 기준으로는 1달 정도 늦었지만, accept 날짜로는 크게 뒤지지는 않게 논문이 나왔다.

Bartel 랩에서 만든 방법하고는 어떤 차이가 있나?

Bartel 랩 방법 (PAL-seq)이 그냥 커피라면 우리 방법 (TAIL-seq)은 TOP.. (먼산)

목적은 비슷하지만 방법은 완전히 다르다. PAL-seq은 시퀀싱 방법 자체를 수정해서 poly(A) 길이에 비례하게 primer extension으로 biotin을 넣도록 한 다음에 streptavidin-fluorophore를 붙여서 한 방에 정량한다. 반면에 우리 방법인 TAIL-seq은 그냥 전통적인 paired-end 시퀀싱을 그대로 매우 오랫동안 한 사이클에 1nt 씩 합성해서 감지한다. 그래서 자연적으로 PAL-seq은 오차가 누적되지는 않기 때문에 긴 poly(A) (주로 150nt이상)를 잘 재는 편이고, TAIL-seq은 오차 누적효과가 있지만 신호의 양이 많고 1 nt단위로 신호가 나오기 때문에 150nt보다 짧은 것들을 잘 잰다.

무엇보다 큰 차이는 PAL-seq은 시퀀싱 과정 자체를 교체해버리기 때문에, 시퀀서를 직접 가지고 아주 모험적인 프로토콜을 공들여서 하는 사람들이 아니면 직접 써 보기 어렵지만, TAIL-seq은 그냥 세계 어디서나 일루미나 기계만 갖고 있으면 할 수 있기 때문에 굉장히 만만하다. citation은 우리꺼야 냠냠

그래 됐고, 얻은 결론은 뭔가?

두 논문에서 우리가 발견한 것을 요약하면 다음과 같다.

  • mRNA poly(A)뒤에 U가 생각보다 많음. 특히 짧은 poly(A)에 몰려 있다.
  • 의외로 poly(A) 길이는 mRNA translation과 전반적으로는 별 관련이 없다.
  • mRNA poly(A)뒤에 G도 제법 있는데, 이건 기능도 모르고 누가 붙이는 지도 모름. 아마도 mRNA를 보호하는 효과가 있지 않을까 예상.
  • poly(A) 뒤에 U 붙이는 것은 TUT4와 TUT7이 하는데, 두 단백질이 다 없어지면 세포가 죽는다.
  • TUT4와 TUT7을 줄이면 mRNA가 전반적으로 오래 사는 걸 봐서, U가 붙는건 mRNA decay에 관련있는 듯.
  • TUT4와 TUT7은 내재적으로도 짧은 poly(A)를 선호하고, poly(A)에 PABP까지 붙으면 그 경향이 더 뚜렷해진다.

베스트샷 하나만 꼽는다면?

TAIL-seq 자체에 대한 앞 논문 Fig. 1번도 좋아하긴 하지만 (ㅋㅋ 쑥스..), 미관상 이번 논문의 아이스크림 막대기 모양 그림이 마음에 든다. 보기만 해도 좀 달달하고 좋지 않나? ㅎ;;;

mRNA-urid-fig6-original

그럼 다음엔 뭐하나?

우선 우리가 원래 가장 궁금했던 TUT4와 TUT7의 mRNA꼬리에 U붙이기 문제는 이번 논문으로 해결했고, 다음에 뻔히 나올 수 있는 여러 분야에 적용하고 있는 중이다. poly(A)가 조절되는 걸 가지고 할 수 있는 후속 연구란 너무 뻔해서 비밀이라고 해도 다 예상이 가능할 것 같다. =.=

그리고, TAIL-seq 첫 논문에서 알고리즘에 대해서는 자세히 언급됐지만, 프로그램이 아직 공개되어 있지 않다. 프로그램은 지금 사람과 쥐가 아닌 다른 종에도 쉽게 적용할 수 있도록 아주 단순한 범용 프로그램으로 새로 만들고 있다. 기존 프로그램은 계산량이 워낙 많아서 클러스터 없이는 분석이 힘들었지만, 이번에 필요없는 계산을 많이 제거하고 Julia로 주요 부분을 거의 교체했다. 다른 일이 많이 겹쳐 있어서 늦어지고 있지만, 아마도 2015년 2분기 중에는 공개할 수 있을 것 같다.

연구에 참여한 사람과 사사

전체 연구 계획과 설계, 진행은 김빛내리 교수님, 장혜식, 임재철, 하민주 박사가 했습니다. TAIL-seq 기술은 장혜식, 임재철이 공동으로 설계하고 개발했습니다. 두 논문 모두 주요 생화학 실험은 임재철, 하민주 박사가 수행했습니다. 두 번째 논문 (Uridylation..)에는 권성철 박사, Dr. Dhirendra Simanshu가 실험을 도왔습니다. 이 연구는 기초과학연구원(IBS)의 연구비 지원으로 진행되었습니다.

BGZF 파일 여러 개 합치기

  • bed 파일에 2억 줄 짜리 데이터가 들어있다.
  • 대충 파이썬으로 계산할 걸 만들어보니 1번 염색체 처리에만 싱글 프로세스로 하루 정도 걸렸다.
  • 이런 데이터가 하나만 있는게 아니라 계속 나온다.

자 이런 상황이 있으면 예전 같으면 “C로 잘 짠다”라던지 “파일을 나눠서 클러스터에 넣는다.” 정도가 답이다. GNU parallel 같은 것 써보려고 노력을 해 볼 수도 있고.

전에도 블로그에서 소개한 적이 있는데, 이런 일에 tabix를 쓰면 웬만한 문제는 단번에 풀린다! 오오 위대하신 Heng Li 느님… 텍스트를 압축해 놓고 중간부터 랜덤 액세스가 가능해져서 파일을 나눌 필요가 없다보니, 파일을 나누거나 parallel을 쓸 때 엄청나게 낭비되는 I/O 시간이 절약된다. 물론 결과도 tabix에서 쓰는 bgzf로 미리 압축해서 저장하면, 그냥 이어붙이기만 하면 아무 문제 없이 다시 다음 과정도 또 분산해서 처리가 가능해진다. 포맷도 bed만 되는게 아니라 탭으로 구분된 데이터를 정렬하기만 하면 된다.

그런데 이게 이론적으로는 그냥 이어붙이기만 하면 잘 돼야하지만, 의외로 잘 안 된다. 어떻게 이어 붙여도 첫 번째 파일만 인식하고 나머지는 무시한다. 그래서 압축을 풀었다가 다시 압축을 하는 방법으로 구질구질하게 해 봤지만, 멀티쓰레드 지원하는 pbgzip은 랜덤한 버퍼 에러가 수시로 난다. (고치려고 해봤지만 실패 orz)

그래서 이리 저리 고민을 해 보다가, tabix를 패치하자니 EOF블럭이 중간에 들어있는 파일은 좀 변태같기도 하고 나이가 드니까 패치하고 올리고 하기도 귀찮고 해서 그냥 EOF 블럭을 빼고 파일을 합치는 툴을 하나 만들었다.

여기 –> bgzf-merge.py

요걸로 bgzf 합치면 짠! 하고 깔끔하게 tabix나 기타 bgzf 입력 받는 툴들이 제대로 돈다. 물론 속도는 풀었다가 다시 합치는 것하고는 비교가 안 된다. ㅋㅋ

요렇게~

아. 이것은 어디까지나 평소에 놀고 있는 클러스터가 옆에 있을 때 얘기다. 없으면…… 그냥 C로 고고 -ㅇ-;;

한국에서 23andMe 하는 과정

처음으로 대중적인 관심을 집중적으로 받은 오락유전체학 (entertainment genomics) 상품인 23andMe가 서비스를 시작한지 3년이 다 돼가고 있습니다. 언론과 학계의 관심 뿐만 아니라 사업적으로도 상당히 잘 되고 있다는 것이 제법 긍정적이죠. 이게 처음에 워낙 비싸서 정말 얼리어답터나 유전병에 관심이 많은 사람이 아니면 하기가 어려웠지만, 이제는 200달러에 비교적 쉽게 침을 한 번 뱉어볼 수 있는 정도가 됐습니다. 그래서 아 그래 나도 맨날 엄한 암세포나 대장균 유전자말고 내 유전자하고 좀 놀아보자 하고 마음먹고 해 보았지요~

이미 홍창범님김형용님께서 해 보셨기 때문에 조언을 얻어서 진행해 보았습니다. 미국에 막 부탁할 만한 지인이 없는 경우도 쉽게 할 수 있도록 최대한 지인활용을 배제하는 것을 원칙으로.. (?;;)

신용카드 결제하기

우선 23andMe 사이트에 가서 주문합니다. 그런데, 주문할 때 아주 큰 문제는 한국을 결제 주소로 쓸 수가 없고, 신용카드와 결제주소가 영 안 맞으면 결제가 거부되기 때문에, 이 부분에 한해서는 지인을 활용하여 외국 신용카드를 써야합니다. 미국 카드도 괜찮고 일본 카드도 되는데, 저는 일본에 계신 거친마루님께 제가 침을 좀 뱉어보고자 하니 결제 한 번만 해 주십시오 하여 대신 결제를 해 주셨습니다. ^_^ (감사!) 단, $99에 1년 서비스를 추가 결제해야되게 돼 있어서 여러 번 신세를 지지 않으려면 선물세트($207)로 사는 것이 더 편하겠습니다.

23andMe 키트

키트 배송 방법

침을 담아가는 키트를 받을 때 주소로 한국을 쓸 수 없어서 지인을 활용하거나 요즘 아주 많은 배송대행 업체를 써야 합니다. 배송대행도 요새 크게 비싼 편이 아니라서 배송대행을 하기로 하고, 가격도 비교적 싸고 잔문제가 별로 없었던 세븐존이라는 업체에 보냈습니다. 오레곤으로 보내면 미국 내 세금이 안 붙는 대신 좀 느리고, 캘리포니아로 보내면 순식간에 도착하는 대신 세금이 붙습니다. 키트가 워낙 가벼워서 배송비와 대행료를 합쳐서 13000원이면 충분했습니다.

국내 통관

통관용 물품신고를 배송대행업체에 신청할 때 작성해야하는데요, 여기서 얼마로 쓰느냐에 따라서 세금이 달라집니다. 보통 통관할 때 구입한 사이트와 똑같이 생긴게 가격도 같으면 빨리 된다길래 $99로 신고했더니만 관세가 꽤 붙어버렸네요. 2개 통관에 45590원 냈습니다. 그런데 나중에 확인해보니까 키트를 다시 받을 때 23andMe가 받는 가격이 $25라고 하니까, $25로 신고하면 관세도 안 붙고 좋을 것 같은데.. 다음엔 그 쪽으로 해 봐야겠네요;

침 뱉기

튜브가 도착하면 침을 모아서 잘 뱉으면 됩니다. 사용방법은 친절하게 써 있으니 그냥 그대로 하면 되고, 뚜껑에 모여있는 보존액이 충분히 침하고 섞어주고 열심히 흔들어서 DNA분해효소를 무력화하는 것이 중요합니다. 튜브는 DNA Genotek의 ORAGene-DNA OG-500의 OEM 제품이 옵니다. 자세한 정보는 매뉴얼에 안 써 있는데, 특허출원내용을 보면 뚜껑에 있는 보존액의 주성분은 Mg2+를 잡아서 DNase 활성을 막아주는 킬레이팅 에이전트 (EDTA일 확률이 높지만 CDTA, DTPA, DOTA, TETA 등도 언급되어 있음)와, 단백질 구조를 풀어서 DNase를 너덜너덜(;)하게 해 주는 환원제 (2-mercaptoethanol, DTT, dithionite, ascorbic acid 등 여러가지 중 하나), 항산화제 (항산화 비타민 등), pH 유지를 위한 버퍼 에이전트 (Tris, HEPES 등 중 하나)를 포함하고 있다고 합니다. 예상가능한 조합이지만 구체적으로 뭐라고 안 써놔서 실제로 받은 키트에 뭐가 들어있을지는 모르겠네요. DTT나 2-mercaptoethanol 특유의 냄새는 전혀 안 나는 걸 봐서는 보통 쓰는 조합은 아니거나 농도가 비교적 낮은 모양입니다.

침은 모두 2ml 뱉도록 되어있는데 최종적으로는 보존액 2ml과 합쳐서 4ml이 됩니다. 신기하게도 여기서 DNA를 뽑으면 무려 110µg 정도 얻을 수 있다는군요. (침에 DNA가 이렇게 많았다니!!)

미국으로 다시 보내기

23andMe에서 직접 샘플을 처리하는 것이 아니고 LA에 있는 National Genetics Institute라는 의료용 유전자진단 전문 회사가 처리합니다. 그래서 침도 여기로 보내도록 주소가 되어 있습니다. 23andMe에서 미국 내 반송용으로는 선불택배를 지불해 놓았지만, 한국에서는 보낼 수 없으니까 마찬가지로 지인 활용을 할 수도 있고, 직접 보내는 것도 사실 가격 차이는 없습니다. 특히 NGI가 LA공항 바로 옆에 있으니까 훨씬 빨리 가겠죠~ (그냥 상상;;) 우체국 EMS에서 2개를 반송하는데 22000원으로 생각보다 싸게 보낼 수 있었습니다. 통관물품신고는 저는 홍창범님이 하신 대로 Toy, $10으로 신고하고 조마조마 하고 있었는데, 나중에 보니 공항에서 받자마자 바로 통관되는 게 크게 까다로운 것 같지는 않네요. 정식으로 하자면 보존액처리된 침 샘플은 미국 항공안전규정에서 생물학적 규제 면제 품목에 해당돼서 법적으로 큰 문제가 없지만, 많은 배송업체들이 침 샘플을 다룬 경험이 거의 없어서 엉뚱하게 돌려보내는 경우도 있다고 하네요. 눈치껏 잘 하면 됩니다. ^^; 저는 EMS를 한국에서 발송하고 4일 만에 NGI에 도착했습니다.

결과

결과는 받고 나서 6주 후에 나온다고 하는데.. 전 아직 안 받아서 나중에 받으면~~ ^^;