2018년 10월 12일 금요일

Awk를 이용한 간단한 텍스트 파일 조작(join)

Awk는 개발자 세 사람 - Alfred Aho, Peter Weinberger 및 Brian Kerninghan - 이름의 머리글자를 따서 명명된 프로그래밍 언어이다. 이들은 전부 1940년대 초반에 출생하신 원로이다. 아마추어 무선을 하는 사람을 일컫는 ham (ham radio)역시 무선통신의 역사를 세운 세 사람의 이름인 Hertz, Armstrong, Marconi에서 왔다는 설도 있으나 이는 근거가 없다고 한다(Etymology of ham radio). 여담이지만 우리나라에서는 radio라 하면 공중파 방송을 듣는 라디오 수신기만을 생각하지만, 미국에서는 무선을 이용한 모든 활동을 뜻하는 것 같다. 무전기(휴대용 및 거치형 전부)를 two-way radio라고도 부른다고 하니 말이다.

우선 첫번째 AWK 사례를 설명해 보자. 왼쪽과 같이 의 조합으로 이루어진 단순한 텍스트 파일이 있다고 가정한다. 이를 오른쪽처럼 변환하려면 어떻게 하면 좋을까?


이때 awk의 associative array(예제 링크)가 쓰이게 된다. Associative array는 숫자가 아닌 것도 인덱스로 사용할 수 있는 배열을 의미한다. Perl에서 흔히 사용하는 hash와 매우 유사하다. 위에서 기술한 매우 간단한 과업을 수행하려면 다음의 명령어 한 줄이면 된다.
$ awk -F, '{a[$1] = a[$1] FS $2} END{for (i in a) print i a[i]}' file
-F,는 입력 파일의 field separator를 쉼표로 지정한다는 뜻이다. {}으로 둘러싼 AWK 명령어 내부에서는 FS가 같은 의미로 쓰였다.

다음으로는 두 텍스트 파일을 공통된 컬럼 값으로 묶는 약간 복잡한 응용 사례이다. Using Awk to join two files based on several columns를 참조하였다. 여러 컬럼으로 이루어진 두 개의 텍스트 파일 file_a.bed와 file_b.bed가 있다고 가정하자. 첫번째 파일의 컬럼 1-2와 두번째 파일의 컬럼 3-4가 동등한 종류의 내용을 담고 있다. 빨갛게 표시한 것은 값 자체가 같은 것이다. 컬럼을 구분하는 것은 공백이 아니고 탭문자이다.

$ cat file_a.bed
chr1 123 aa b c d
chr1 234 a b c d
chr1 345 aa b c d
chr1 456 a b c d
$ cat file_b.bed 
xxxx abcd chr1 123 aa c d e
yyyy defg chr1 345 aa e f g

이때 다음과 같이 두번째 파일에서 동일한 라인에 해당되는 결과를 끌어다가 첫번째 파일의 해당되는 라인에 출력하는 것이 목적이라고 하자. 아래 그림에서는 파랑색으로 표시하였다.

chr1 123 aa b c xxxx abcd
chr1 234 a b c
chr1 345 aa b c yyyy defg
chr1 456 a b c

다음과 같이 awk 명령어를 작성하면 된다(명령어 1).

$ awk 'NR==FNR{a[$3,$4,$5]=$1OFS$2;next}{$6=a[$1,$2,$3];print}' OFS='\t' file_b.bed file_a.bed

읽어 들인 라인에 대해서 associative array가 어떻게 작동하는지는 이 글의 뒷부분에 [Awk associative array의 활용 예제]를 통해서 설명하였다. 먼저 awk의 내장 변수에 대해서 이해를 넓힐 필요가 있다. NR는 현재 처리 중인 라인 번호를 의미한다. Awk가 여러 파일을 처리하는 경우 NR은 전체 파일에 대한 라인 번호를, FNR은 각 입력 파일에 대한 라인 번호를 의미한다. 다음을 실행해보면 감이 잡힐 것이다. 인수로 주어진 파일은 두 개이지만, awk는 라인 단위로 순차적인 처리를 하게 되므로 첫 번째 파일(file_a.bed)에 대해서 작업을 먼저 한 뒤, 이어서 두 번째 파일(file_b.bed)를 가지고서 일을 하게 된다.

$ awk '{print FILENAME, NR, FNR}' file_a.bed file_b.bed
file_a.bed 1 1
file_a.bed 2 2
file_a.bed 3 3
file_a.bed 4 4
file_b.bed 5 1
file_b.bed 6 2

명령어 1을 실행할 때 언제 NR==FNR{}의 조건을 충족하게 되는가? 첫 번째 파일에 대한 작업을 할 때이다. 즉, 첫번째 인수로 주어진 파일(file_a.bed)에 대해서 {} 내의 명령을 실행하라는 뜻이다. next는 그 다음에 따라오는 {} 명령어 블록을 실행하지 말라는 뜻이다. 이것은 대단히 중요하지만 약간 어려운 개념이다. 그림으로 설명을 해 보겠다. 부디 내가 이해한 것이 옳기를 바란다. 별도로 작성한 글 awk와 sed를 사용하여 multi-fasta 파일의 서열 ID를 일괄적으로 바꾸기에서도 next문의 중요성을 설명하였다.

numbers라는 파일은 각 라인에 1..10까지의 숫자를 수록하고 있다. 각 라인의 수와 홀수 또는 짝수줄 여부를 화면으로 프린트하려면 어떻게 하는지를 다음의 그림에 설명하였다. next가 왜 중요한지를 잘 보여준다.


그러면 다시 원래의 awk 문으로 돌아가자. 두번째 인수로 주어진 file_a.bed에 대해서는 NR==FNR이 FALSE가 된다. 따라서 두번째 {} 내부의 명령어를 수행하게 된다. 즉 file_a.bed를 읽어서 마지막 필드($6)를 associative array의 값으로 바꾸어서 프린트하게 된다.

어쩌면 if..else를 쓰는 것이 좀 더 명확할지도 모르겠다.

$ awk '{if(NR==FNR){a[$3,$4,$5]=$1OFS$2}else{$6=a[$1,$2,$3];print}}' OFS='\t' file_b.bed file_a.bed

첫번째 주어진 답을 다르게 표현하면 이렇게 된다. 오히려 이것도 이해하기 쉬운 해결책이다.

$ awk 'NR==FNR{a[$3,$4,$5]=$1OFS$2}NR!=FNR{$6=a[$1,$2,$3];print}' OFS='\t' file_b.bed file_a.bed

awk에서 다음을 잘 구별하는 노력이 필요하다.
  • 조건{...} {...} <= 조심해서 써야 한다!
  • 조건{...;next}{...} <= 이 사례는 의외로 유용하다. 어떤 조건을 만족하는 라인은 조작을 가해서 출력하고, 조건을 충족시키지 않는 것을 그대로 출력하는데 좋다. FASTA file의 서열 ID만 바꾸는 경우가 이에 해당한다. sed로도 이렇게 할 수 있을까>?
  • 조건1{...} 조건2{...}
  • if문을 사용하는 것

[업데이트] Awk associative array의 활용 예제

이 글을 추가로 써 넣으면서 걱정이 앞서기 시작하였다. awk는 그렇게 고수준의 언어가 아니라서 복잡한 일을 시키기는 곤란하다. 아래에서 든 예제 및 결과를 나중에 다시 찾아보았을때 어떻게 동작하는지를 이해하지 못하게 될까봐 고민이다. 자주 읽어보고 실습을 해 보는 것이 해결책이다.

두 개의 파일(file_a, file_b)를 갖고 있다고 가정하자. 첫번째 파일 file_a는 자료를 담고 있고, 이 중에서 첫번째 컬럼에 특정 값이 있는 경우 그 라인만을 뽑아내고 싶다. 해당되는 값은 두번째 파일 file_b의 첫번째 컬럼(key)에 담겨 있다. 만약 file_b가 하나의 컬럼만으로 구성되어 있다면 shell 환경에서 grep -f file_b file_a라고 입력하면 된다. 그러나 파일이 길어지면 속도가 느려질 것이 뻔하고(file_b에 수록된 패턴 한 줄에 대해서 file_a 전체를 훑는 일을 계속 반복해야 하므로), file_a의 해당되는 라인을 출력하는 것 이외의 일은 하지 못할 것이다.

$ cat file_a
1 A
2 B
3 C
4 D
5 E
6 F
7 G
8 H
9 I
10 J
$ cat file_b
7 seven
3 three
6 six
2 two

첫번째 파일을 그대로 출력하되 두번째 파일에서 정의된 라인 뒤에 특별한 표시를 하는 것이 작업 목표이다. 표시할 값은 두번째 파일의 두번째 컬럼(value)에서 나열한 값이다. 실생활에서 쉽게 접할 수 있는 사례를 들자면 한 파일에는 학번과 과목별 점수가, 다른 파일에는 학번과 이름이 적혀 있는 상황에서 학번, 이름, 점수를 표시하는 일과 같은 것을 생각하면 된다. 두 파일에서 동일한 성격의 값이 수록된 필드, 즉 첫번째 컬럼이 동일한지를 살피는 것이 핵심이다. 만약 두번째 파일에 엉뚱하게 '0 zero'가 있다면 어떻게 할 것인가? 이것까지는 해결하지 못한다.

$ awk -F '\t' 'NR==FNR{a[$1]=$2;next}{$3=a[$1];print}' OFS='\t' file_b file_a
1 A 
2 B two
3 C three
4 D 
5 E 
6 F six
7 G seven
8 H 
9 I 
10 J 

아니면 지정된 라인만 출력하고 싶다. 이건 조금 더 까다롭다. 왜냐하면 if(..){..} 구문이 들어가야 하기 때문이다.


$ awk -F '\t' 'NR==FNR{a[$1]=$1;next}{if($1==a[$1]){print}}' OFS='\t' file_b file_a
2 B
3 C
6 F
7 G

지정된 라인만 출력하되 약간의 장식을 하려면 두번째의 array가 필요해진다. b[$1]=1이라 선언하고 뒤의 if문에서 b[$1]==1로 하는 것이 좀 더 간단하다. Perl이라면 hash를 이용하면 되고, if exists $seen{$key} 구문을 이용하여 주어진 key를 갖는 hash가 존재하는지를 점검할 수 있다. 그러나 awk에서는 그렇게까지 하지 못하기 때문에 두번째의 associative array인 b[]를 도입하게 된 것이다.

$ awk -F '\t' 'NR==FNR{a[$1]=$2;b[$1]=$1;next}{if(b[$1]==$1){$3="FOUND!";print}}' OFS='\t' file_b file_a
2 B FOUND!
3 C FOUND!
6 F FOUND!
7 G FOUND!

$ awk -F '\t' 'NR==FNR{a[$1]=$2;b[$1]=$1;next}{if(b[$1]==$1){$3=a[$1];print}}' OFS='\t' file_b file_a
2 B two
3 C three
6 F six
7 G seven

위의 사례에서는 file_a에서 원하는 라인을 그대로 출력해 놓고 라인의 끝에 추가적인 컬럼을 덧붙이는 형식이었다. 군더더기 없이 file_a를 그대로 출력하되 숫자 자리(첫번째 컬럼)를 아예 치환하고 싶다면?

$ awk -F '\t' 'NR==FNR{a[$1]=$2;b[$1]=$1;next}{if(b[$1]==$1){$1=a[$1];print}}' OFS='\t' file_b file_a
two B
three C
six F
seven G

위 예문을 기초로 하여 여러 가지 응용과 변형을 가해 보는 것이 좋을 것이다.

댓글 없음: