Этим летом мне представилась возможность принять участие в микростажировках Open Avenues. В рамках своего проекта я использовал методы обработки естественного языка и машинного обучения для классификации отчетов медицинской радиологии. Этот блог официально подводит итоги проекта, над которым я работал последние 8 недель.

Обработка естественного языка

С популяризацией таких инструментов, как ChatGPT, обработка естественного языка (NLP) стала вызывать больший интерес в области компьютерных наук и искусственного интеллекта. Проще говоря, НЛП фокусируется на взаимодействии между компьютерами и естественным человеческим языком, с основной целью дать компьютерам возможность понимать, интерпретировать и генерировать человеческий язык.

В этом проекте я стремлюсь использовать обработку естественного языка (NLP) и методы машинного обучения, чтобы научить компьютеры классифицировать и прогнозировать категории разделов в образцах отчетов по медицинской радиологии.

МетаФлоу

Для проведения анализа в этом проекте будет использоваться MetaFlow.
MetaFlow, библиотека Python с открытым исходным кодом, созданная Netflix, предназначена для оптимизации и управления обширными проектами по обработке и анализу данных, что особенно полезно для обработки сложных шагов в крупномасштабных проектах. В MetaFlow проект организован в отдельные потоки, каждый из которых состоит из нескольких шагов, помеченных ключевым словом @step.

Хорошо, идем!

Анализ данных с рентгенологическими отчетами

В этом конкретном разделе я объясню, почему важно знать свой набор данных. Глубокое понимание ваших данных похоже на наличие прочной базы для принятия разумных решений о том, как их организовать, особенно для обучения вашего компьютера классификации.

Отчеты о медицинской радиологии разделены на четыре основные категории: Результаты, Клинические данные, Название исследования и Впечатления. Результаты включают наблюдения, сделанные радиологами во время обследования, Клинические данные (показания) обозначают цель или причину проведения обследования, Название исследования указывает тип проведенного обследования, а Впечатления — это заметные клинические открытия. Ниже приведен пример, иллюстрирующий структуру типичного отчета:

Еще раз повторюсь, моя цель — научить компьютер точно определять соответствующий раздел, к которому принадлежит данное тело текста.

Теперь, когда вы лучше понимаете предысторию этого проекта, вот код!

Импорт данных

Первый шаг моего потока включает импорт набора данных и сбор данных из каждого столбца. Ключевое слово self. используется для создания переменных, которые можно использовать на следующем шаге. Мы делаем это, чтобы отслеживать данные, которыми мы хотим делиться между шагами, поскольку каждый шаг в потоке выполняется независимо друг от друга.

from metaflow import FlowSpec, step

class NLPFlow(FlowSpec):
    
    @step
    def start(self):
        import pandas as pd
        self.df = pd.read_csv("open_ave_data.csv")
        self.df.fillna("nan",inplace=True)
        self.find = self.df["findings"].values.tolist()
        self.clin = self.df["clinicaldata"].values.tolist()
        self.exam = self.df["ExamName"].values.tolist()
        self.impr = self.df["impression"].values.tolist()
        self.corpus = self.find + self.clin + self.exam + self.impr
        self.next(self.eda)

Исследовательский анализ данных

Отлично, теперь мы переходим к следующему этапу исследовательского анализа данных (EDA) с использованием функции self.next().

@step
    def eda(self):
        import matplotlib.pyplot as plt
        import numpy as np
        from functions import eda_len, eda_stop, eda_words, bigrams, wordcloud
        
        labels = ["impressions", "findings", "clinical data", "exam"]
        # character length
        eda_len(plt, labels, self.find, self.clin, self.exam, self.impr)
        # stop words for impressions 
        eda_stop(plt, self.impr)
        # length of words
        eda_words(plt,labels, self.find, self.clin, self.exam, self.impr)
        # list of bigrams
        bigrams(plt, self.corpus)
        # wordcloud
        wordcloud(plt, self.corpus)
        
        self.next(self.preproc)

При использовании метапотока всегда рекомендуется делать код достаточно кратким. Вы заметите, что я импортирую множество функций из файла functions.py, где я определил свой собственный набор функций для использования в сочетании с метапотоком. Итак, вот каждая функция, которая используется из этого файла, и соответствующий вывод графика для EDA.

Средняя длина символа

# function to calculate the mean length of each category
def eda_len(plt, labels, find, clin, exam, impr):
    plt.clf()
    # impressions
    impr_lengths = np_length(impr)
    # findings
    find_lengths = np_length(find)
    # clinical data
    clin_lengths = np_length(clin)        
    # exams
    exam_lengths = np_length(exam)
    
    char_len = [impr_lengths, find_lengths, clin_lengths, exam_lengths]
    x = range(len(char_len))
    plt.bar(x, char_len)
    plt.xticks(x,labels)
    plt.savefig('text_length.png')

# helper function to get the mean length of a list
def np_length(lis):
    np_lis = np.array(lis)
    get_len = np.vectorize(len)
    str_len = get_len(lis)
    len_mean = np.mean(str_len)
    return len_mean

Среднее количество слов

def eda_words(plt, labels, findings, clinical, exam, impression):
    import nltk
    plt.clf()
    impr_counts = [len(nltk.word_tokenize(sentence)) for sentence in impression]
    impr_words = np.mean(impr_counts)
    
    find_counts = [len(nltk.word_tokenize(sentence)) for sentence in findings]
    findings_words = np.mean(find_counts)
    
    clin_counts = [len(nltk.word_tokenize(sentence)) for sentence in clinical]
    clin_words = np.mean(clin_counts)
    
    exam_counts = [len(nltk.word_tokenize(sentence)) for sentence in exam]
    exam_words = np.mean(exam_counts)

    words_bar = [impr_words, findings_words, clin_words, exam_words]
    x = range(len(words_bar))
    plt.bar(x, words_bar)
    plt.xticks(x,labels)
    plt.savefig('words_length.png')

20 лучших стоп-слов

Стоп-слова — это термин, используемый в НЛП для обозначения определенных слов в языке, которые часто встречаются, но не имеют существенного значения. Когда дело доходит до создания языковых моделей, мы часто удаляем стоп-слова, чтобы сократить время обработки или повысить эффективность. Для анализа данных мы рассмотрим эти стоп-слова, но по мере продвижения мы будем удалять их во время предварительной обработки. Для следующего графика я нанес 20 лучших стоп-слов из категории показов.

# takes a body of text and finds the top 20 stop words
def eda_stop(plt, lis):
    # nltk.download('punkt') # you will need this if you have not downloaded this already
    from nltk import FreqDist
    from nltk.corpus import stopwords
    from nltk.tokenize import word_tokenize
    
    plt.clf()
    stop=set(stopwords.words('english'))
    words = list(map(lambda x : word_tokenize(x),lis))
    words = [word for i in words for word in i]
    stop_words = [i for i in words if i in stop]
    stop_freq = FreqDist(stop_words)
    top_20 = stop_freq.most_common(20)
    
    labels = [item[0] for item in top_20]
    values = [item[1] for item in top_20]
    
    plt.bar(labels, values, width=0.6)
    plt.savefig('impressions_stop_words.png')

N-грамм

В области НЛП N-граммы представляют последовательности из n элементов в тексте, которые могут быть как символами, так и словами. В этом случае я сосредоточусь на вычислении биграмм из всего корпуса (всех документов из набора данных), по сути определяя наиболее распространенные последовательности из двух слов в наборе данных.

def bigrams(plt, corpus):
    import pandas as pd
    from nltk import RegexpTokenizer
    from nltk.util import ngrams
    from nltk import FreqDist
    plt.clf()
    tokenizer = RegexpTokenizer(r'\b\w+\b')
    # Tokenize sentences into words
    tokenized_sentences = [tokenizer.tokenize(sentence) for sentence in corpus]
    # Create bigrams by words
    bigrams = [list(ngrams(sentence, 2)) for sentence in tokenized_sentences]
    bigrams = [bi for lis in bigrams for bi in lis]
    bi_freq = FreqDist(bigrams)

    # plotting the bigrams
    bi_fdist = pd.Series(dict(bi_freq.most_common(20)))
    bi_fdist.plot.bar()
    plt.xlabel('Bigram')
    plt.ylabel('Frequency')
    plt.title('Top 20 Bigrams')
    plt.tight_layout()  # Ensures labels are not cut off
    plt.savefig('bigram_bar_chart.png')  # Save the plot as an image

Вы заметите, что в приведенном выше коде я использую RegexpTokenizer, таким образом, я могу указать последовательность символов, которые я хочу считать токенами или словами. Таким образом, я буду избегать ненужных небуквенно-цифровых токенов, таких как точки, запятые, квадратные скобки или точки с запятой.

WordCloud

Последним сюжетом, который я вам покажу, будет сюжет WordCloud. Облака слов — это представления текстовых данных, в которых размер слов на изображении соответствует их важности или частоте в данных. Я буду использовать модуль wordcloud Python для его создания.

def wordcloud(plt, corpus):
  import wordcloud
  from wordcloud import WordCloud
  from nltk.corpus import stopwords
  text = ' '.join(corpus)
  wordcloud = WordCloud(
      width=1800, 
      height=1400,
      max_words=100)
  
  wordcloud=wordcloud.generate(text)
  plt.figure(figsize=(10, 5))
  plt.imshow(wordcloud, interpolation="bilinear")
  plt.axis("off")
  plt.savefig('word_cloud.png')

Выглядит отлично! Давайте перейдем к этапу предварительной обработки языковых моделей, которые мы будем использовать.

Предварительная обработка

Предварительная обработка — это шаги, которые необходимо предпринять для очистки и преобразования данных, чтобы их можно было использовать для анализа и языковых моделей. Наши шаги предварительной обработки будут включать удаление нежелательных символов, удаление стоп-слов, токенизацию (разделение предложений на отдельные слова или токены) и перевод в нижний регистр.

@step    
def preproc(self):
    from functions import target_vals, clean_data
    
    self.clean_corpus = list(map(clean_data,self.corpus))
    self.corpus = [' '.join(words) for words in self.clean_corpus] # joining the words in a single string for ease of processing
    self.target_vals = target_vals(self.find, self.clin, self.exam, self.impr)
    self.colors = ['red', 'green', 'blue','yellow']
    self.next(self.tfidf, self.word2vec) # next steps will be in parallel   

Вот функции clean_data() и target_vals() из нашего файла functions.py:

# function to clean data for processing
def clean_data(w):
    import re
    from nltk.corpus import stopwords
    stopwords_list = stopwords.words('english')
    clean_corpus = []
    w = w.lower()
    w=re.sub(r'[^\w\s]','',w)
    words = w.split() 
    clean_words = [word for word in words if (word not in stopwords_list) and len(word) > 2]
    return clean_words

# getting the target values for the model prediction
def target_vals(find, clin, exam, impr):
    # repeating the label value for the length of the documents in the column
    f_y = [0] * len(find)
    c_y = [1] * len(clin)
    e_y = [2] * len(exam)
    i_y = [3] * len(impr)

    # combine all the labels
    category_labels = f_y + c_y + e_y + i_y
    return category_labels

Вы могли заметить, что я создал функцию под названием target_vals(). При создании моделей классификации важно обучать их с помощью размеченных данных. В машинном обучении эта концепция называется Обучение с учителем. Данные помечаются определенными категориями, и модель учится различать закономерности в данных и делать соответствующие прогнозы. Чтобы оценить точность этих прогнозов, мы используем целевые метки, которые в этом примере представляют правильные категории для всех документов, на которых будет тестироваться модель.

Следующий шаг — или, скорее, следующие несколько шагов — будет выполняться параллельно. Использование мощной функциональности ветвей MetaFlow. Мы достигаем этого, используя несколько параметров в нашей функции self.next() в конце нашего шага. Наш модуль метапотока будет распараллеливать шаги для нас, и нам не придется беспокоиться о том, как мы будем использовать ядра на нашем компьютере.

Частота термина — обратная частота документа (TD-IDF)

Частота терминов — обратная частота документов — это процесс количественной оценки важности слов в наборе документов. Частота термина вычисляет частоту слова в документе. Обратная частота документа вычисляет важность слова в коллекции всех документов. Оба этих значения создают разреженное векторное представление для документа. Это числовое представление можно использовать для сравнения или классификации.

Вот следующие шаги для TF-IDF:

@step
    def tfidf(self):
        from sklearn.feature_extraction.text import TfidfVectorizer
        
        vectorizer = TfidfVectorizer()
        self.tfidf_documents = vectorizer.fit_transform(self.corpus)
        self.next(self.tfidf_plot)
@step
    def tfidf_plot(self):
        from sklearn.decomposition import PCA
        import matplotlib.pyplot as plt
        from functions import tfidf_plot
        labels = ['Findings', 'Clinical Data', 'Exam Name', 'Impressions']
        tfidf_plot(self.tfidf_documents, plt, self.colors, labels)
        self.tfidf_documents
        self.next(self.join)

Поскольку TF-IDF являются векторами, эти значения можно представить в виде графика.

def tfidf_plot(tfidf, plt, colors, labels):
    print("tfidf plot")
    from sklearn.decomposition import PCA

    pca = PCA(n_components=2)
    tfidf_matrix_2d = pca.fit_transform(tfidf.toarray())
    
    color = -1
    label = -1
    for i, document in enumerate(tfidf_matrix_2d):
        if i % 954 == 0:
            color+=1
            label+=1
        x_coords = document[0]
        y_coords = document[1]
        plt.scatter(x_coords, y_coords, color=colors[color], label=labels[label])

    # Set plot title and axis labels.
    plt.title("TF-IDF Matrix Scatter Plot")
    plt.xlabel("Dimension 1")
    plt.ylabel("Dimension 2")
    # Display the scatter plot.
    plt.savefig("tfidf.png")

Как вы можете видеть на графике выше, каждая точка представляет собой документ, а каждый цвет соответствует категории. Вы можете увидеть, как похожие документы обычно группируются вместе. Это также помогает количественно определить границы категории, что может быть полезно при классификации.

В следующих параллельных шагах мы изучим Word2Vec, еще одну форму векторного представления.

Word2Vec

Word2Vec — это метод изучения встраивания слов. Он представляет слова как плотные векторы в непрерывном пространстве. В этом проекте мы будем использовать Word2Vec для представления слов в документе. Вот следующий шаг потока:

@step
def word2vec(self):
    import multiprocessing
    from gensim.models import Word2Vec
    import numpy as np
    from functions import word2vec_avg
    
    self.corpus = self.clean_corpus
    cores = multiprocessing.cpu_count()
    self.w2v_model = Word2Vec(min_count=5,window=5,vector_size=300,workers=cores-1,max_vocab_size=100000)
    self.w2v_model.build_vocab(self.corpus)
    self.w2v_model.train(self.corpus,total_examples=self.w2v_model.corpus_count,epochs=50)
    self.document_vectors = np.array(word2vec_avg(self.find, self.clin, self.exam, self.impr, self.corpus, self.w2v_model))
    self.next(self.word2vec_plot)

Единственная загвоздка здесь в том, что каждый вектор Word2Vec представляет только одно слово, но для нашего анализа нам нужно, чтобы он представлял весь документ. Поэтому нам нужно усреднить все векторы Word2Vec из каждого слова в каждом документе. Тогда усредненный вектор будет представлением этого документа. Вот код для этого:

# averages all the word2vec vectors for each document
def word2vec_avg(find, clin, exam, impr, corpus, model):
    f = len(find)
    c = len(clin)
    e = len(exam)
    i = len(impr)

    document_vectors = []
    for doc in corpus:
        vectors = [model.wv[word] for word in doc if word in model.wv]
        if vectors:
            doc_vector = np.mean(vectors, axis=0)
        else:
            doc_vector = np.zeros(300)
        document_vectors.append(doc_vector)
        
    return document_vectors

Затем давайте нанесем эти векторы на график, чтобы понять, как они выглядят:

@step
def word2vec_plot(self):
    from sklearn.manifold import TSNE
    import matplotlib.pyplot as plt  
    from functions import word2vec_plot      
    
    tsne = TSNE(n_components=2, random_state=42)
    vectors_2d = tsne.fit_transform(self.document_vectors)
    word2vec_plot(plt, vectors_2d, self.colors)
    self.document_vectors
    self.target_vals
    self.next(self.join)
def word2vec_plot(plt, vectors_2d, colors):
    print("word2vec plot")
    color = -1
    plt.figure(figsize=(10, 8))
    for i, word in enumerate(vectors_2d):
        if i % 954 == 0:
            color+=1
        x, y = vectors_2d[i, :]
        plt.scatter(x, y, c=colors[color])
    plt.savefig("word2vec.png")

График выглядит немного разреженным и сильно отличается от TF-IDF, но помните, что эти значения усреднены, что может повлиять на разницу во внешнем виде графика.

Нашими следующими шагами будет тестирование наших моделей TF-IDF и Word2Vec, чтобы определить, какая из них лучше предсказывает категории.

Логистическая регрессия

Логистическая регрессия — это алгоритм статистического и машинного обучения, используемый для задач бинарной классификации, цель которых — предсказать вероятность принадлежности выборки данных к определенному классу. Хотя этот проект содержит несколько классов или категорий. Это просто означает, что модели придется адаптировать мультиклассификацию к бинарному сценарию. Модель будет разделена на несколько моделей, по одной для каждой категории, где положительный прогноз — это тот, который соответствует правильной категории, а тот, который соответствует другой, неправильной категории, будет считаться отрицательным прогнозом. Здесь нам нужно будет использовать target_vals, который мы определили ранее.

Вот как создать модель логистической регрессии:

def logistic_train(x,target): # target vals is input for this function
    from sklearn.linear_model import LogisticRegression
    from sklearn.model_selection import train_test_split
    from sklearn.metrics import accuracy_score
    
    X = np.array(x)
    y = np.array(target)

    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=24)
    log_reg = LogisticRegression()

    log_reg.fit(X_train, y_train)
    y_pred = log_reg.predict(X_test)
    
    # Calculate accuracy of the model
    return accuracy_score(y_test, y_pred)

Метрики

После обучения и тестирования модели рассчитаем точность работы модели. Точность — это один из многих показателей, которые мы можем использовать для определения того, насколько «хорошо» работает модель. Он рассчитывается путем оценки количества точных прогнозов по отношению к общему количеству тестовых образцов. Хотя использование точности может быть ценной метрикой, другие меры, такие как точность, полнота или F-оценка, также полезны для оценки производительности. Несмотря на свои ограничения, во многих случаях точность все еще может быть практичной и информативной метрикой.

Вот шаги для обучения моделей Word2Vec и TF-IDF:

# word2vec training step
@step
def word2vec_train(self):
    import numpy as np
    from functions import logistic_train
    target_vals = self.target_vals
    # calculate w2v accuracy
    self.w2v_acc = logistic_train(self.document_vectors, target_vals)
    self.next(self.join)

# tfidf training step
@step
def tfidf_train(self):
    import numpy as np
    from functions import logistic_train
    # calculate tfidf accuracy
    tfidf_arr = self.tfidf_documents.toarray()
    self.tfidf_acc = logistic_train(tfidf_arr, self.target_vals)
    self.next(self.join)

Следующий шаг в потоке объединяет два параллельных процесса и сравнивает точность двух моделей.

# compare the models in join step
@step
def join(self, inputs):
    import numpy as np
    from functions import logistic_train
    # calculate w2v accuracy
    print("W2V Accuracy:", inputs.word2vec_train.w2v_acc)
    print("TFIDF Accuracy:", inputs.tfidf_train.tfidf_acc)
    self.next(self.end)

Затем мы увидим вывод терминала о выполнении этого потока и решим, что лучше для этой задачи — TF-IDF или Word2Vec. Я запущу поток, введя команду python <file.py> run:

Здесь мы видим, что каждый шаг завершился успешно, и наша модель Word2Vec имеет точность 99,6%, а TF-IDF — 99,7%.

Стоит отметить, что этот набор данных относительно небольшой и содержит всего 954 отчета. В результате при многократном запуске и тестировании моделей разница в точности между ними была минимальной. Этот пример стал первым случаем, когда модели демонстрировали значительные различия в значениях для случайного начального числа при создании разделения обучения и тестирования.

Краткое содержание

В общем, результаты немного предсказуемы. Word2Vec — это модель, которая представляет каждое словов виде вектора в непрерывном пространстве. Он идеально подходит для ситуаций, когда важна семантика, потому что он может помочь определить сходство между словами, но это означает, что он рассматривает слово не в контексте документа, а само по себе или его соседство с другими словами. Отсюда, почему его общая производительность была ниже — хотя и ненамного.

В этом конкретном случае TF-IDF имеет преимущество из-за учета контекста слова в документе, а не отдельных вхождений. Этот подход включает как частоту слова в документе, так и его частоту во всех других документах, чтобы сформировать разреженный вектор.
Например, термин "ВПЕЧАТЛЕНИЕ" может появляться только несколько раз в любом документе категории показов. Таким образом, хотя частота терминов в документах может быть низкой, по сравнению с другими документами, это слово с большей вероятностью будет появляться в категории показов, что подчеркивает его важность с точки зрения обратной частоты документов. Таким образом, повышается точность классификации.

С более широкой точки зрения, определение наиболее подходящей модели зависит от конкретной рассматриваемой проблемы. Каждый подход имеет свои сильные стороны и ограничения. Очень важно тщательно взвесить эти факторы и сопоставить их с целями проблемы, которую вы решаете. Это тщательное понимание гарантирует, что выбранная модель хорошо работает с конкретными трудностями и деталями вашего проекта.

Вот мой полный код по моей ссылке Проект GitHub.

Спасибо за чтение!

Лорен Уильямс