생물정보학자를 위한 파이썬 클래스 튜토리얼

목차

1. 우리가 만들 Seq 클래스 미리 살펴보기

서열 데이터는 DNA 혹은 RNA 를 이루는 염기들의 순서를 나타내는 정보인데요. 생물정보학을 공부하고 계시는 분이라면 이러한 염기서열 데이터를 기본적으로 많이 다루고 계실 겁니다. 이는 주로 네 종류의 글자들(A, T, G, C)로 이루어진 데이터이므로 종종 파이썬의 문자열 형식(str)으로 취급하여 사용합니다. 또한 염기서열 데이터를 가지고 전사(transcription), 번역(translation) 혹은 상보적 서열(reverse compliment)을 구해야 하는 등의 여러 작업들도 많이 이루어집니다. 하지만 이러한 작업들을 하기 위해서는 매번 함수들을 불러와 사용하는 것이 번거로울 수 있는데요. 이러한 기능들이 모두 메소드로 구현되어 있어 사용하기 편리한 사용자 정의 클래스를 같이 만들어 보려고 합니다.

조금 더 구체적인 예제를 통해 설명해 보겠습니다. 앞서 말한 것과 같이, 만약 파이썬의 일반적인 문자열 형식으로 서열 데이터를 다룬다면 아래와 같은 모습일 겁니다. 작업에 필요한 함수들을 여러분이 갖고 계신 모듈 (여기에서는 seq_module 이라고 했습니다.)로부터 임포트(import)해야 할 테구요. 그리고 서열 정보를 문자열로 담은 뒤에 각각의 함수들을 호출하여 사용하는 방식일 겁니다.

from seq_module import transcribe
from seq_module import reverse_compliment
from seq_module import gc_content

seq = 'GATTACA'

transcribe(seq)  # 'GAUUACA'
reverse_compliment(seq)  # 'TGTAATC'
gc_content(seq)  # 0.2857142857142857

하지만, 우리가 만들 Seq 클래스를 이용한다면 다음과 같이 관련 작업들을 모두 메소드를 이용하여 편리하게 사용할 수 있습니다. 위와 아래의 차이점이 잘 보이시나요? 또한 작업에 관련된 여러 함수들을 일일이 불러오는 대신에 Seq 클래스 하나만 임포트 해주어도 된다는 간결함은 구현된 클래스를 사용하면서 얻는 또 하나의 장점입니다.

from seq_module import Seq

seq = Seq('GATTACA')

seq.transcribe()  # 'GAUUACA'
seq.reverse_compliment()  # 'TGTAATC'
seq.gc  # 0.2857142857142857

자, 어떠신가요? 한번 같이 만들어 봐도 좋을 것 같나요? 그렇다면 다음으로 넘어가 차근 차근 하나씩 만들어 봅시다!

2. 시작이 반이다! 클래스의 선언

함수를 만들때는 def 지시어를 사용하죠? 클래스를 만들 때에는 class 라는 지시어를 사용합니다. 아래와 같이 Seq 이라는 이름을 가진 클래스를 선언해 보았습니다. 파이썬에서 클래스의 이름은 대문자로 시작하는 것을 권장하므로 특별한 이유가 없는 한 지켜주시는 것이 좋습니다.

class Seq(object):
    ...

그렇다면 클래스를 사용하려고 할 때 내부적으로는 어떤 일들이 벌어질까요? 바로 클래스 내부의 __init__ 이라는 초기화 함수가 호출됩니다. 따라서 이러한 초기화 함수를 구현해 주어야 합니다. 아래와 같이 서열 정보를 받아 저장하는 기본적인 기능만 갖춘 초기화 함수를 만들어 보았습니다.

class Seq(object):

    def __init__(self, data):
        self._data = data

초기화 함수인 __init__ 은 첫 번째 인자로 self 라고 하는 인스턴스를 가리키는 변수를 받고, 그 다음으로 클래스를 호출할 때 넘겨주는 인자들을 차례대로 받아올 수 있습니다. 우리가 넘겨주는 서열 데이터는 data 에 담겨 들어오게 됩니다. 이를 인스턴스의 변수인 self._data 에 저장하는 기능이 담긴 초기화 함수를 작성해 주었습니다.

자, 그렇다면 기본적인 기능이 잘 작동하는지 확인해 봐야겠죠? 아래와 같이 서열 정보 하나를 클래스를 이용해 선언해 주고 인스턴스 변수에 접근하여 데이터가 잘 저장되었는지를 확인해 봅시다.

seq = Seq('GATTACA')

seq  # <__main__.Seq object at 0x7f1606b70690>
seq._data  # 'GATTACA'

클래스 내부에 선언되어 특별한 기능을 갖는 함수들을 매직 메소드라고 하는데요. 이러한 매직 메소드들의 이름은 더블 언더스코어(__)로 시작되는 것이 특징입니다. 방금 여러분들은 첫 번째 매직 메소드를 익히셨습니다!

3. 서열에도 종류가 있다. DNA 와 RNA 를 구분해 봅시다.

단지 문자열만 담을 거였다면 애초부터 Seq 클래스를 만들어 보자고 하지 않았을 겁니다. 조금 더 실용적으로 사용할 수 있도록 염기 서열의 종류도 구분하여 갖고 있을 수 있도록 조금 더 수정해 봅시다. 바로 초기화 함수에서 moltype (molecular type 이라는 뜻입니다.) 이라는 변수를 추가로 받도록 설계하면 됩니다. 그리고 만약에 지정하지 않았다면 기본적으로는 DNA 라고 생각하도록 만들어 줍시다. 다음과 같이 말입니다.

class Seq(object):

    def __init__(self, data, moltype='dna'):
        self._data = data
        self._moltype = moltype

자, 이제 염기서열이 DNA 인지, RNA 인지도 구분하여 저장할 수 있게 되었습니다! 이제, 다음과 같이 RNA 서열도 표현할 수 있는 똑똑한 클래스가 만들어졌습니다.

dnaseq = Seq('GATTACA')
dnaseq._data  # 'GATTACA'
dnaseq._moltype  # 'dna'

rnaseq = Seq('GAUUACA', moltype='rna')
rnaseq._data  # 'GAUUACA'
rnaseq._moltype  # 'rna'

제가 서열의 종류도 구분하여 저장하자고 한 이유는 다음과 같은 예제로 확인할 수 있습니다. 아직 reverse_compliment() 메소드를 구현하지는 않았으므로 이 예제는 그냥 눈으로만 보셔도 됩니다. 같은 서열을 갖더라도 DNA 인지 RNA 인지에 따라, 상보적인 염기 서열을 구하는 작업의 결과가 달라질 수 있습니다. DNA 에서는 A 에 상보적인 염기는 T 인데 반해, RNA 에서는 A 에 상보적인 염기는 U 이기 때문입니다.

dnaseq = Seq('GAACA', moltype='dna')
dnaseq.reverse_compliment()  # Seq('TGTTC', moltype='dna')

rnaseq = Seq('GAACA', moltype='rna')
rnaseq.reverse_compliment()  # Seq('UGUUC', moltype='rna')

4. 짖궂은 사용자를 방어하자: 예외 처리

여러분이 만든 Seq 클래스를 혼자서만 사용한다면 문제가 없지만 다른 사람에게 여러분이 만든 클래스를 사용하도록 건네준다면 조금 더 생각해야 할 부분이 있습니다. 바로 예외 처리입니다. 다른 사람들은 종종 여러분이 만든 클래스를 어떻게 사용해야 하는지 모를 수 있습니다. 따라서 클래스를 이용하여 인스턴스를 만들 때 우리가 예상하지 못했던 잘못된 값을 넣을 경우가 발생할 것입니다. 저는 이것을 종종 ‘짖궂은 사용자를 위한 프로그래밍’ 이라고 하는데요. 클래스를 잘못 사용하는 것을 넘어 클래스를 가지고 짖궂은 장난을 치는 사용자가 있다고 가정하면 조금 더 예외 처리를 해야 하는 이유가 확 와닿기 때문입니다.

seq = Seq(['a', 'b', 'c', 'd'], moltype=list)
seq = Seq('Hello, world!', moltype='what?')

정말 짖궂은 사용자이죠? 이러한 값들이 들어왔을 때 여러분들의 클래스는 초기화 시에 들어온 값들을 샐틈없는 로직에 근거하여 예측하지 못한 이상한 값들이 들어왔다면 에러를 출력하여 인스턴스를 만들 수 없음을 알려 주어야 합니다. 먼저, 초기화 시에 넘겨주는 datamoltype 변수의 타입부터 문자열 타입인 str 이 제대로 들어왔는지를 체크하는 부분을 추가해 봅시다.

class Seq(object):

    def __init__(self, data, moltype='dna'):
        if not isinstance(data, str):
            raise TypeError("Sequence should be string.")
        self._data = data
        if not isinstance(moltype, str):
            raise TypeError("moltype should be string.")
        self._moltype = moltype

그리고 염기 서열의 종류는 DNA 혹은 RNA 이므로, moltype 변수는 'dna' 혹은 'rna' 라는 문자열 값만을 가지도록 제한해 보도록 합시다. 그리고 DNA 또는 RNA 와 같이 대문자로 입력한 사용자도 문제없이 클래스를 사용할 수 있도록 소문자로 변환하는 부분을 추가해 줍시다. 그 외의 값이 들어오는 경우에는 ValueError 를 출력하도록 해줍시다.

class Seq(object):

    def __init__(self, data, moltype='dna'):
        if not isinstance(data, str):
            raise TypeError("Sequence should be string.")
        self._data = data
        if not isinstance(moltype, str):
            raise TypeError("moltype should be string.")
        moltype = moltype.lower()
        if moltype not in ('dna', 'rna'):
            raise ValueError(f"Not allowed moltype: {moltype}")
        self._moltype = moltype

우와, 벌써 초기화 함수의 덩치가 좀 커졌네요. 이제 왠만한 에러는 잡아낼 수 있게 되었습니다. 한번 테스트해 볼까요?

seq = Seq(['a', 'b', 'c', 'd'], moltype=list)
# Traceback (most recent call last):
#   File "main.py", line 15, in <module>
#     seq = Seq(['a', 'b', 'c', 'd'], moltype=list)
#   File "main.py", line 5, in __init__
#     raise TypeError("Sequence should be string.")
# TypeError: Sequence should be string.

seq = Seq('Hello, world!', moltype='what?')
# Traceback (most recent call last):
#   File "main.py", line 15, in <module>
#     seq = Seq('Hello, world!', moltype='what?')
#   File "main.py", line 11, in __init__
#     raise ValueError(f"Not allowed moltype: {moltype}")
# ValueError: Not allowed moltype: what?

Seq 클래스를 사용할 때 data 변수에 리스트를 넣어주었더니 형식이 맞지 않는다며 TypeError 로 알려주고, moltype 변수에 'dna' 혹은 'rna' 라는 문자열 외의 값을 넣어주었더니 허락된 값이 아니라며 ValueError 로 알려줍니다.

자, 이제 한 가지의 예외 처리가 남았습니다. data 변수에는 DNA 서열을 입력하면서 moltype 변수에는 RNA 서열이라고 입력한 경우에는 어떨까요? 아직 이 부분에 대한 에러 처리는 되어 있지 않습니다. 우리는 이미 DNA 서열에 허락되는 염기들과 RNA 서열에 허락되는 염기들을 알고 있습니다. 따라서 그 부분을 정의해 주고 data 변수로 받은 데이터가 moltype 변수로 들어온 서열의 종류에 따라 허락되지 않는 염기 문자열이 있는지를 검사하면 됩니다. 초기화 함수 __init__ 의 맨 뒷부분에 다음과 같이 추가해 줍니다.

DNA_BASES = 'ACGTNacgtn'
RNA_BASES = 'ACGUNacgun'


class Seq(object):

    def __init__(self, data, moltype='dna'):
        ...

        allowed_bases = {'dna': DNA_BASES, 'rna': RNA_BASES}[self._moltype]
        if not set(self._data).issubset(set(allowed_bases)):
            raise ValueError("Sequence includes not allowed bases.")

이 예외 처리가 없을 때에는 에러 없이 그대로 진행되었을 예제를 이용하여 이제 예외 처리가 제대로 동작하는지 살펴 봅시다.

seq = Seq('GAUUACA', moltype='dna')
# Traceback (most recent call last):
#   File "main.py", line 21, in <module>
#     seq = Seq('GAUUACA', moltype='dna')
#   File "main.py", line 19, in __init__
#     raise ValueError("Sequence includes not allowed bases.")
# ValueError: Sequence includes not allowed bases.

우라실(U)이 포함된 서열을 넣어주면서 DNA 라고 알려주니, 서열이 잘못된 염기를 포함하고 있다고 에러로 잘 알려줍니다. 이로써 조금 더 친절하고 안전하게 사용할 수 있는 Seq 클래스가 되었습니다.

5. 필요할 때 계산해서 알려주는 변수같은 함수: 프로퍼티 데코레이터 (@property)

클래스를 통해 만들어진 인스턴스는 메소드(함수) 이외에도 self._data 또는 self._moltype 처럼 변수를 가질 수 있습니다. 이러한 변수들은 이미 계산되어져 저장되어 있는 값으로, 접근하기만 하면 우리는 그 값을 확인할 수 있습니다. 하지만, 우리가 확인하려고 하는 값이 접근만 하면 되는 것이 아니라 필요할 때 즉시 계산해야 하는 경우나 몇 가지 작업이 동반되어야 하는 경우에는 함수를 호출하거나 해당 함수를 프로퍼티라는 데코레이터를 사용하여 마치 변수처럼 사용할 수 있습니다.

구체적인 예제를 통해 살펴보겠습니다. 생물정보학에서 자주 다루는 값 중 하나로, 서열에 구아닌(G)과 사이토신(C)이 얼마나 포함되어 있는지를 나타내는 비율을 GC content 라 합니다. 이는 주어진 서열이 모두 아데닌(A) 이나 티민(T)으로 이루어져 있다면 0.0 의 값으로, 모두 구아닌(G) 또는 사이토신(C)으로 이루어져 있다면 1.0 의 값으로 표현됩니다. 이러한 GC content 를 미리 계산하여 seq.qc 와 같은 인스턴스 변수에 넣어둔 뒤에 필요할 때 접근하여 사용할 수도 있겠지만, 중간에 서열이 변경되는 경우에는 변경된 서열과 앞서 계산해 둔 GC content 값이 서로 맞지 않는 문제가 발생할 수 있습니다.

# Create a 'seq' instance and calculate GC content
seq = Seq('GATCA')
seq.qc = 2 / 5  # 0.4

# Change the sequence through seq._data variable
seq._data = 'GACCG'  # GC content of the sequence: 4 / 5 (0.8)
print(seq.qc)  # It still has the GC content of old sequence (0.4)

따라서, 이러한 경우에 gc 라는 이름의 변수 대신에 함수를 다음과 같이 구현하여 사용할 수 있습니다. 이제 서열이 중간에 업데이트 되더라도 GC content 가 필요할 때 seq.qc() 와 같이 함수를 호출하여 값을 확인할 수 있습니다.

class Seq(object):
    ...

    def gc(self):
        """GC content"""
        length = len(self._data)
        if length == 0:
            return 0.5
        return sum(self._data.count(base) for base in 'CcGg') / length


seq = Seq('GATTACA')
seq.gc()  # 0.2857142857142857

하지만, 아까와 같이 변수의 형태로 사용할 수는 없을까요? 즉, seq.qc() 가 아니라 seq.qc 처럼요. 이것이 바로 프로퍼티 데코레이터가 하는 일입니다. 이 함수의 윗부분에 @property 라는 데코레이터를 씌워주게 되면 아래와 같이 마치 변수에 접근하는 형태이지만 내부적으로는 gc 라는 함수를 호출하여 그 반환값을 마치 변수에 저장되어 있었던 값인 것 처럼 돌려줍니다.

class Seq(object):
    ...
    
    @property
    def gc(self):
        """GC content"""
        length = len(self._data)
        if length == 0:
            return 0.5
        return sum(self._data.count(base) for base in 'CcGg') / length


seq = Seq('GATTACA')
seq.gc  # 0.2857142857142857

이와 같이 함수에 넣어주는 인자값이 self 외에는 존재하지 않으면서 함수의 의미가 해당 인스턴스의 ‘속성’ 과 같은 의미로 사용된다면 프로퍼티 데코레이터의 사용을 고려해보시면 좋습니다.

6. 인스턴스를 문자열로 표시하는 방법: __repr____str__ 매직 메소드

파이썬 콘솔(IDLE 혹은 Jupyter Notebook)과 같은 환경에서 우리가 만든 인스턴스인 seq 만 입력하고 엔터를 쳐 봅시다. 이때 나오는 문자열은 어떻게 만들어지는 것일까요? 그리고 조금 더 정보가 담기도록 수정해볼 순 없을까요?

seq
# <__main__.Seq object at 0x7fbfdfe30450>

우리가 배워 볼 두 번째 매직 메소드를 소개합니다. __repr__ 이라는 매직 메소드인데요. 이는 파이썬에서 인스턴스를 문자열로 표시해주기 위한 방법을 정의하는 함수입니다. 아래와 같이 정의해 주면,

class Seq(object):

    def __init__(self, data, moltype):
        ...

    def __repr__(self):
        return f"{self.__class__.__name__}({self._data!r}, moltype={self._moltype!r})"

이를 이용하여 다음과 같이 seq 을 다시 쳐 봤을 때 표시되는 문자열이 달라집니다.

seq = Seq('GATTACA')

seq
# Seq('GATTACA', moltype='dna')

__repr__ 말고도 이와 비슷한 매직 메소드로 __str__ 이 있습니다. 이는 빌트인 함수 str() 에 넣었을 때 호출됩니다. 우리는 seq 인스턴스를 다시 문자열 형식으로 가져오고 싶을 때가 있을 것입니다. seq._data 변수에 직접 접근하는 방법이 있겠지만 보통의 경우에는 이렇게 언더스코어(_)가 붙은 변수는 마치 클래스 내부에서만 사용하는 private 변수처럼 사용하는 것을 권장합니다. (다른 언어들과 달리 파이썬은 강제하는 규칙이 없지만 관례에 따라 지켜주는 것이 좋습니다.) 따라서 문자열 형식으로 가져오는 기능을 위해 __str__ 를 통해 구현하는 방법을 많이 택합니다. 아래와 같이 빌트인 함수 str() 를 통해 __str__ 매직 메소드가 호출되면, 인스턴스가 갖고 있는 self._data 변수를 돌려주도록 해 봅시다.

class Seq(object):

    def __init__(self, data, moltype):
        ...

    def __repr__(self):
        return f"{self.__class__.__name__}({self._data!r}, moltype={self._moltype!r})"

    def __str__(self):
        return self._data

위와 같이 __str__ 함수를 추가하면, 다음과 같이 str() 함수를 사용했을 때 문자열 형식을 받아올 수 있습니다.

seq = Seq('GATTACA')

str(seq)  # 'GATTACA'

7. 서열의 길이는 어떻게 구할까?: __len__ 매직 메소드

문자열의 경우에 빌트인 함수 len() 을 이용하면 문자열의 길이를 알 수 있습니다. 이와 마찬가지로 서열 데이터에서도 len() 을 호출하면 서열의 길이를 리턴하도록 만들어 봅시다. 이는 내부적으로 클래스에 정의된 __len__ 이라는 매직 메소드를 호출하는데요. 지금은 이 메소드가 구현되어 있지 않으므로 다음과 같은 에러가 날 것입니다.

seq = Seq('GATTACA')

len(seq)
# Traceback (most recent call last):
#   File "main.py", line 25, in <module>
#     len(seq)
# TypeError: object of type 'Seq' has no len()

다음과 같이 __len__ 매직 메소드를 구현해 주었습니다. 빌트인 함수 len() 에 의해 호출이 되면, 인스턴스가 갖고 있는 서열 데이터인 self._data 의 길이를 구하여 리턴해주도록 하였습니다.

class Seq(object):
    ...

    def __len__(self):
        return len(self._data)

그러면 다음과 같이 우리가 만든 클래스에도 길이라는 개념이 있기 때문에 len() 함수를 호출하면 서열의 길이를 알 수 있습니다.

seq = Seq('GATTACA')

len(seq)  # 7

8. 반복문을 돌릴 수 있을까?: __getitem__ 매직 메소드

seq = Seq('GATTACA')

for base in seq:
    print(base)
    # G
    # A
    # T
    # T
    # A
    # C
    # A

위와 같이 서열에서 염기 하나씩 반복하고 싶을 경우에는 어떻게 할까요? Seq 클래스의 인스턴스에도 반복문(for)을 사용할 수 있게 하려면 어떤 매직 메소드가 필요할까요? 이에 해당하는 것이 바로 __getitem__ 입니다.

class Seq(object):
    ...

    def __getitem__(self, index):
        """Return a subsequence of this sequence."""
        return Seq(self.data[index], moltype=self.moltype)

9. 두 서열의 비교, 같음의 정의: __eq__ 매직 메소드

같은 서열로 만든 두 개의 서열이 있을 때 이 둘이 같은지를 비교하면 우리는 당연히 같다는 결과를 줄 것임을 예상합니다. 하지만, 실제로 비교 연산자인 == 를 이용해 비교해 보면 아래와 같이 우리가 예상했던 것과는 다른 결과를 보여줍니다.

seq1 = Seq('GATTACA')
seq2 = Seq('GATTACA')

seq1 == seq2  # False

분명히 서열도 같은데, 왜 다르다고 할까요? 이는 바로 클래스에 __eq__ 라는 매직 메소드가 정의되어 있지 않기 때문입니다. 함수 __eq__ 가 정의되어 있지 않으면 대신에 두 인스턴스가 존재하고 있는 메모리 상의 위치를 비교하는 is 연산자의 결과값을 가져오기 때문에 False 라는 결과를 얻은 것입니다. 우리는 메모리 상의 위치가 아니라 두 서열이 같으면 동일한 서열이라고 하고 싶으므로 그 의도에 맞게 아래와 같이 self._dataother._data 의 비교를 통해 구현하면 됩니다. __eq__ 함수는 self 다음의 인자로 비교 대상이 되는 other 라는 또 다른 seq 인스턴스를 인자로 받는 것이 특징입니다.

class Seq(object):
    ...

    def __eq__(self, other):
        return self._data == other._data


seq1 = Seq('GATTACA')
seq2 = Seq('GATTACA')

seq1 == seq2  # True

이제 우리가 의도했던 대로 잘 동작합니다. 하지만 아래의 예제를 보면 동등함의 정의를 조금 더 수정해야 함을 알 수 있습니다. 비록 서열은 같지만 서열의 종류가 한 쪽은 DNA, 다른 한 쪽은 RNA 일 때는 다르다고 해야 할 것입니다.

seq1 = Seq('GAACA', moltype='dna')
seq2 = Seq('GAACA', moltype='rna')

seq1 == seq2  # True

따라서 __eq__ 함수를 조금 더 수정하여 아래와 같이 _moltype 변수의 비교도 고려하여 작성할 수 있습니다.

class Seq(object):
    ...

    def __eq__(self, other):
        return (self._data == other._data) and (self._moltype == other._moltype)


seq1 = Seq('GAACA', moltype='dna')
seq2 = Seq('GAACA', moltype='rna')

seq1 == seq2  # False

이제 서열은 같지만 서열의 종류가 다른 경우에는 다르다고 표현하는 더욱 똑똑한 Seq 클래스가 만들어 졌습니다.

10. 두 서열의 비교, 순서의 정의: __gt____lt__ 메소드

이번에는 두 서열이 다를 때 어떤 서열이 더 큰지 혹은 작은지를 결정하는 기능도 넣어봅시다.

(작성 예정)

11. 조금 더 쉽게 클래스 만들기: 클래스 확장

여러분께 고백할 것이 하나 있습니다. 클래스를 확장하면

우리가 앞서 구현했던 매직 메소드들 중 문자열 데이터를 다루는 것과 특별히 다른 것이 없는 것들이 있었습니다.

UserStr 상속을 통해 …