最近在做天池醫療AI大賽,看到一份不錯的資料,想翻譯一下做個記錄,原鏈接點擊打開鏈接
原作者JonathanMulholland
以下是翻譯內容
在本教程中,我們將展示一種方法來根據患者的CT掃描圖像來判斷患者是否患有癌症或者可能在未來12個月內發展成癌症患者。
我們將訓練一個網絡來分割出潛在的癌性結節,然後使用這些分割出的結點的特徵來對CT掃描病人未來12個月的病情診斷進行預測。
教程代碼位於點擊打開鏈接
依賴和工具
-
numpy
-
scikit-image
-
scikit-learn
-
keras(tensorflow backend)
-
matplotlib
-
pydicom
-
SimpleITK
注意:Keras訓練允許使用多種backend。我們選擇安裝支持GPU的tensorflow作爲Keras的backend。(譯者注:keras可以選擇tensorflow或者theano作爲keras的backend,因爲keras的框架有基於tensorflow的版本和theano的版本)。
爲了識別具有結節的區域,我們將使用U-Net風格的卷積網絡,這個網絡主要用於做分割。點擊打開鏈接
我們的網絡代碼是基於MarkoJocic在Kaggle論壇上爲UltrasoundNerve Segmentation挑戰發佈的教程。點擊打開鏈接
我們用於預測癌症診斷的圖像是(CT)掃描圖像。CT掃描中結節的外觀暗含了患者患有癌症的可能性的信息,我們將需要通過訓練標記好的結節的樣例來訓練U-net神經網絡,再通過訓練好的網絡來尋找結節。我們不採用手動標記圖像,而選擇LungNodule Analysis 2016 (LUNA2016) challenge中提供的標記好了結節位置的CT圖像。我們將首先使用LUNA數據集爲我們的U-Net生成適當的訓練集。我們將使用這些訓練集來訓練我們的有監督分割器。
從LUNA2016數據構建訓練集
我們將使用annotations.csv中給出的結節位置,並從每個病人掃描中提取包含最大結節的三個橫切片。將根據annotations.csv中給出的結節尺寸爲這些切片創建掩碼。該文件的輸出將是每個病人掃描的兩個文件:一組圖像和一組相應的結節掩模。來自LUNA2016挑戰的數據可以在這裏找到點擊打開鏈接
首先我們導入必要的工具,並在病人的CT掃描中找到最大的結節。一些患者在annotations.csv中有多個結節。我們使用名爲df_node的DataFrame(pandas中的數據結構)來跟蹤病例編號和節點信息。這些節點信息是後綴爲.mhd文件中定義的座標系的(x,y,z)座標(mm)。
以下代碼片段來自LUNA_mask_extraction.py:
import SimpleITK as sitk import numpy as np import csv from glob import glob import pandas as pd file_list=glob(luna_subset_path+"*.mhd") ##################### # # Helper function to get rows in data frame associated # with each file def get_filename(case): global file_list for f in file_list: if case in f: return(f) # # The locations of the nodes df_node = pd.read_csv(luna_path+"annotations.csv") df_node["file"] = df_node["seriesuid"].apply(get_filename) df_node = df_node.dropna() ##### # # Looping over the image files # fcount = 0 for img_file in file_list: print "Getting mask for image file %s" % img_file.replace(luna_subset_path,"") mini_df = df_node[df_node["file"]==img_file] #get all nodules associate with file if len(mini_df)>0: # some files may not have a nodule--skipping those biggest_node = np.argsort(mini_df["diameter_mm"].values)[-1] # just using the biggest node node_x = mini_df["coordX"].values[biggest_node] node_y = mini_df["coordY"].values[biggest_node] node_z = mini_df["coordZ"].values[biggest_node] diam = mini_df["diameter_mm"].values[biggest_node]
在mhd文件中獲取結節位置
結節的位置相對於由CT掃描儀定義的以毫米爲單位的座標系。圖像數據數由不同長度的大小爲512×512的array構成。爲了將體素位置轉換爲世界座標系,我們需要知道[0,0,0]體素的真實座標和體素的間距(mm)。
爲了找到一個結節的體素座標,根據其真實的位置,我們使用itk圖像對象的GetOrigin()和GetSpacing()方法:
itk_img = sitk.ReadImage(img_file)
img_array = sitk.GetArrayFromImage(itk_img) # indexes are z,y,x (notice the ordering)
center = np.array([node_x,node_y,node_z]) # nodule center
origin = np.array(itk_img.GetOrigin()) # x,y,z Origin in world coordinates (mm)
spacing = np.array(itk_img.GetSpacing()) # spacing of voxels in world coor. (mm)
v_center =np.rint((center-origin)/spacing) # nodule center in voxel space (still x,y,z ordering)
結節的中心位於img_array的v_center[2]切片中。我們將節點信息傳遞給make_mask()函數,並複製生成的掩碼給v_center[2]切片圖像以及其上方和下方的切片圖像。
i = 0 for i_z in range(int(v_center[2])-1,int(v_center[2])+2): mask = make_mask(center,diam,i_z*spacing[2]+origin[2],width,height,spacing,origin) masks[i] = mask imgs[i] = matrix2int16(img_array[i_z]) i+=1 np.save(output_path+"images_%d.npy" % (fcount) ,imgs) np.save(output_path+"masks_%d.npy" % (fcount) ,masks)
值得注意的是,在make_mask()函數中,掩碼座標必須與array的座標的順序相匹配。x和y排序被翻轉。請參閱下面代碼中的最後一行的下一個代碼:
def make_mask(center,diam,z,width,height,spacing,origin): ... for v_x in v_xrange: for v_y in v_yrange: p_x = spacing[0]*v_x + origin[0] p_y = spacing[1]*v_y + origin[1] if np.linalg.norm(center-np.array([p_x,p_y,z]))<=diam: mask[int((p_y-origin[1])/spacing[1]),int((p_x-origin[0])/spacing[0])] = 1.0 return(mask)
我們應該從每次掃描收集更多的切片嗎?
由於結節位置是根據球體定義的,並且結節是不規則形狀的,球體邊緣附近的切片可能不含結節組織。使用這樣的切片可能產生假陽性導致污染訓練集。對於這個分割任務,可能存在一個最優的包含的切片的數量。但是爲了簡單起見,我們只取3張切片,只選擇最大的靠近結節中心的切片。
檢查以確保結節mask如我們所預期:
import matplotlib.pyplot as plt imgs = np.load(output_path+'images_0.npy') masks = np.load(output_path+'masks_0.npy') for i in range(len(imgs)): print "image %d" % i fig,ax = plt.subplots(2,2,figsize=[8,8]) ax[0,0].imshow(imgs[i],cmap='gray') ax[0,1].imshow(masks[i],cmap='gray') ax[1,0].imshow(imgs[i]*masks[i],cmap='gray') plt.show() raw_input("hit enter to cont : ")
結節的中心位於img_array的v_center[2]切片中。我們將節點信息傳遞給make_mask()函數,並複製生成的掩碼給v_center[2]切片圖像以及其上方和下方的切片圖像。
左上角的圖像是掃描切片。 右上角的圖像是節點掩碼(mask)。左下角的圖像是掩碼切片,突出顯示了結節。
結節的近景圖像
提取肺部感興趣區域以縮小我們的結節搜索
節點掩碼似乎已經被正確構建出來了。下一步是提取圖像中的肺部區域。我們需要爲此步驟導入一些skimage圖像處理模塊。總體策略是閾值化圖像以區分出圖像中的各個區域,然後識別哪些區域是肺部。肺與周圍組織有較高的對比,因此閾值相當簡單。我們使用一些臨時的策略來從圖像中消除非肺部區域,這些策略不適用於所有數據集。
導入庫
from skimage import morphology from skimage import measure from sklearn.cluster import KMeans from skimage.transform import resize
這些步驟可以在LUNA_segment_lung_ROI.py中找到
數組被加載爲dtype= np.float64的格式,因爲在Sklearn中的KMeans對於這種精度的數據輸入存在一個bug。
我們將逐步介紹使用img提取肺部ROI的步驟,img是我們通過LUNA2016的數據提取成的切片集合中的一張512×512的切片,它看起來大概類似於下圖:
二值化
我們的第一步是標準化像素值並查看亮度分佈
img = imgs_to_process[i] #Standardize the pixel values mean = np.mean(img) std = np.std(img) img = img-mean img = img/std plt.hist(img.flatten(),bins=200)
-1.5附近的下溢峯值是圖像的黑色掃描儀外部部分。0.0左右的峯值是背景和肺內部,1.0至2.0的大團塊是非肺組織和骨骼。該直方圖的結構在整個數據集中是不同的。下面顯示了兩個數據集的典型圖像。左邊的圖與img類似,在灰色的圓形區域中存在相同的黑色背景。右邊的圖像中不存在黑色背景,因此它們的像素值直方圖差別很大。
我們必須確保我們設置的閾值能夠通過像素值區分肺和密度更高的組織。 爲此,我們將最小值的像素重置爲圖像中心附近的平均像素值,並使用k = 2執行kmeans聚類。 效果似乎不錯。
middle = img[100:400,100:400]
mean = np.mean(middle)
max = np.max(img)
min = np.min(img)
#move the underflow bins
img[img==max]=mean
img[img==min]=mean
kmeans = KMeans(n_clusters=2).fit(np.reshape(middle,[np.prod(middle.shape),1]))
centers = sorted(kmeans.cluster_centers_.flatten())
threshold = np.mean(centers)
thresh_img = np.where(img<threshold,1.0,0.0) # threshold the image
這種方法很好地分離了了兩種類型的圖像區域,並消除了左側的黑色光暈
腐蝕和膨脹
然後,我們使用腐蝕和膨脹來填平(消除)由不透明射線造成的黑色肺部區域,然後根據每個區域的邊界框大小選擇區域。 初始的區域集合大概如下圖所示
eroded = morphology.erosion(thresh_img,np.ones([4,4]))
dilation = morphology.dilation(eroded,np.ones([10,10]))
labels = measure.label(dilation)
label_vals = np.unique(labels)
plt.imshow(labels)
切割非ROI區域
根據經驗確定每個區域邊界框的切割,對於LUNA數據似乎效果很好,但可能不具有普遍性
abels = measure.label(dilation)
label_vals = np.unique(labels)
regions = measure.regionprops(labels)
good_labels = []
for prop in regions:
B = prop.bbox
if B[2]-B[0]<475 and B[3]-B[1]<475 and B[0]>40 and B[2]<472:
good_labels.append(prop.label)
mask = np.ndarray([512,512],dtype=np.int8)
mask[:] = 0
#
# The mask here is the mask for the lungs--not the nodes
# After just the lungs are left, we do another large dilation
# in order to fill in and out the lung mask
#
for N in good_labels:
mask = mask + np.where(labels==N,1,0)
mask = morphology.dilation(mask,np.ones([10,10])) # one last dilation
plt.imshow(mask,cmap='gray')
LUNA_segment_lung_ROI.py中的下一步是將肺部的ROI掩碼應用於每個圖像,裁剪到肺ROI的邊界方框,然後將生成的圖像調整大小爲512×512。
masks = np.load(working_path+"lungmask_0.py")
imgs = np.load(working_path+"images_0.py")
imgs = masks*imgs
裁剪到邊界,並調整大小爲512×512。
然後我們將對像素進行歸一化。 這是因爲掩碼將圖像中的非ROI區域設置爲0,並且該操作對像素值分佈不敏感。 爲了解決這個問題,我們計算掩膜區域的平均值和標準差,並將背景的像素值(現在爲零)設置爲到像素分佈中的較低值(-1.2 * stdev,這是經驗選擇的)。(譯者注:個人理解是之前的 操作使得非ROI區域,即背景區域的像素值爲0,這樣不合適,因此我們將背景區域設置爲一個比較低的像素值,這樣既能使背景區域像素值非0,也保證了它足夠小)
最終結果是一系列可以作爲訓練樣本的肺部圖片。
這些圖像和相應的修剪和重新調整的掩碼被隨機打亂傳遞到一個numpy數組文件中,該數組文件的維數爲[<num_images>,1,512,512]。 由於U-net使用多通道,所以前面的1是必須的。
#
# Writing out images and masks as 1 channel arrays for input into network
#
final_images = np.ndarray([num_images,1,512,512],dtype=np.float32)
final_masks = np.ndarray([num_images,1,512,512],dtype=np.float32)
for i in range(num_images):
final_images[i,0] = out_images[i]
final_masks[i,0] = out_nodemasks[i]
rand_i = np.random.choice(range(num_images),size=num_images,replace=False)
test_i = int(0.2*num_images)
np.save(working_path+"trainImages.npy",final_images[rand_i[test_i:]])
np.save(working_path+"trainMasks.npy",final_masks[rand_i[test_i:]])
np.save(working_path+"testImages.npy",final_images[rand_i[:test_i]])
np.save(working_path+"testMasks.npy",final_masks[rand_i[:test_i]])
您可以通過查看肺部掩膜與和原始文件來驗證ROI分割的效果:
imgs = np.load(working path+'images_0.npy')
lungmask = np.load(working_path+'lungmask_0.npy')
for i in range(len(imgs)):
print "image %d" % i
fig,ax = plt.subplots(2,2,figsize=[8,8])
ax[0,0].imshow(imgs[i],cmap='gray')
ax[0,1].imshow(lungmask[i],cmap='gray')
ax[1,0].imshow(imgs[i]*lungmask[i],cmap='gray')
plt.show()
raw_input("hit enter to cont : ")
Dice係數作爲分割的成本函數
我們將要使用的網絡是教程開端提到的U-net,使用的是keras框架來構建。 損失函數是Dice係數,鏈接點擊打開鏈接
比較了預測和實際的節點掩膜。
以下代碼片段全部取自LUNA_train_unet.py
損失函數如下:
smooth = 1.
# Tensorflow version for the model
def dice_coef(y_true, y_pred):
y_true_f = K.flatten(y_true)
y_pred_f = K.flatten(y_pred)
intersection = K.sum(y_true_f * y_pred_f)
return (2. * intersection + smooth) / (K.sum(y_true_f) + K.sum(y_pred_f) + smooth)
def dice_coef_loss(y_true, y_pred):
return -dice_coef(y_true, y_pred)
該損失函數類似於用於評估該網絡最初編寫的超聲神經分割挑戰的度量(再次參見本教程開頭的鏈接)。
載入分割器
函數調用命令
model = get_unet()
model_checkpoint = ModelCheckpoint('unet.hdf5', monitor='loss', save_best_only=True)
將編譯並返回模型,並告訴keras在checkpoints保存模型的權重。 如果您想從以前的訓練結果加載最佳權重,或使用本教程回購中包含的權重,請使用下面這行命令加載權重文件
model.load_weights('unet.hdf5')
訓練分割器
從命令行調用LUNA_train_unet.py系統將嘗試從當前目錄加載一個unet.hdf5文件,並在腳本中根據下面這行命令設置的參數進行訓練並測試。
model.fit(imgs_train, imgs_mask_train, batch_size=2, nb_epoch=20,
verbose=1, shuffle=True,callbacks=[model_checkpoint])
測試代碼如下
num_test = len(imgs_test)
imgs_mask_test = np.ndarray([num_test,1,512,512],dtype=np.float32)
for i in range(num_test):
imgs_mask_test[i] = model.predict([imgs_test[i:i+1]], verbose=0)[0]
np.save('masksTestPredicted.npy', imgs_mask_test)
mean = 0.0
for i in range(num_test):
mean+=dice_coef_np(imgs_mask_test_true[i,0], imgs_mask_test[i,0])
mean/=num_test
print("Mean Dice Coeff : ",mean)
model.predict()函數操作一次可以測試多個樣本,但因爲我們可以快速重載GPU,我們循環測試各個樣本。
本教程的最終結果是通過GPU機器,使用TitanX訓練得到的。 對於家用GPU計算基準測試,個人設置了GTX970,我們可以在大約一個小時內運行20個epoch,訓練集大小爲320,批量大小爲2。 我們開始約3個小時的訓練後,我們就開始得到相對合理的結節掩膜預測結果,損失值大約0.3 。
這裏給出了從患者掃描獲取的三個切片的一個分割示例。 完整的圓是來自LUNA annotationc.csv文件的“真實”節點掩膜,紅色是分割器的預測節點區域。 原始圖像在右上方給出。
訓練一個識別癌症的分類器
現在,我們已經準備好開始使用我們上一節中的圖像分割來訓練分類器以生成特徵。
The Data Science Bowl訓練數據集必須被放到分割器進行分割,這可以通過重用用於處理LUNA數據的代碼來完成。 但是存在兩點區別:
首先,DSB數據採用dicom格式,可以使用pydicom模塊進行讀取
import dicom
dc = dicom.read_file(filename)
img = dc.pixel_array
其次,爲了在掃描中定位節點,掃描的每一層都必須通過分割器運行,因此每個層也必須進行圖像處理以對ROI進行掩膜運算。 這是一個非常耗時的過程。
基於結節特徵的簡單分類器
我們首先描述結節圖的一些特徵,並將它們放入特徵向量中,這些特徵向量可以用於分類。 我們選取特徵並不詳盡,它的目的在於說明如何發現可用於識別結節所在區域的度量。
我們希望您可以添加一些新功能,探索卷積模型,直接從感興趣的區域提取特徵。 我們已經包括了一些關於圖像中的平均尺寸,形態和位置的特徵,用於建模。
def getRegionMetricRow(fname = "nodules.npy"):
seg = np.load(fname)
nslices = seg.shape[0]
#metrics
totalArea = 0.
avgArea = 0.
maxArea = 0.
avgEcc = 0.
avgEquivlentDiameter = 0.
stdEquivlentDiameter = 0.
weightedX = 0.
weightedY = 0.
numNodes = 0.
numNodesperSlice = 0.
# do not allow any nodes to be larger than 10% of the pixels to eliminate background regions
maxAllowedArea = 0.10 * 512 * 512
areas = []
eqDiameters = []
for slicen in range(nslices):
regions = getRegionFromMap(seg[slicen,0,:,:])
for region in regions:
if region.area > maxAllowedArea:
continue
totalArea += region.area
areas.append(region.area)
avgEcc += region.eccentricity
avgEquivlentDiameter += region.equivalent_diameter
eqDiameters.append(region.equivalent_diameter)
weightedX += region.centroid[0]*region.area
weightedY += region.centroid[1]*region.area
numNodes += 1
weightedX = weightedX / totalArea
weightedY = weightedY / totalArea
avgArea = totalArea / numNodes
avgEcc = avgEcc / numNodes
avgEquivlentDiameter = avgEquivlentDiameter / numNodes
stdEquivlentDiameter = np.std(eqDiameters)
maxArea = max(areas)
numNodesperSlice = numNodes*1. / nslices
return np.array([avgArea,maxArea,avgEcc,avgEquivlentDiameter,\
stdEquivlentDiameter, weightedX, weightedY, numNodes, numNodesperSlice])
def getRegionFromMap(slice_npy):
thr = np.where(slice_npy > np.mean(slice_npy),0.,1.0)
label_image = label(thr)
labels = label_image.astype(int)
regions = regionprops(labels)
return regions
import pickle
def createFeatureDataset(nodfiles=None):
if nodfiles == None:
noddir = "/home/jmulholland/NLST_nodules/"
nodfiles = glob(noddir +"*npy")
# dict with mapping between truth and
truthdata = pickle.load(open("/home/sander/truthdict.pkl",'r'))
numfeatures = 9
feature_array = np.zeros((len(nodfiles),numfeatures))
truth_metric = np.zeros((len(nodfiles)))
for i,nodfile in enumerate(nodfiles):
patID = nodfile.split("_")[2]
truth_metric[i] = truthdata[int(patID)]
feature_array[i] = getRegionMetricRow(nodfile)
np.save("dataY.npy", truth_metric)
np.save("dataX.npy", feature_array)
一旦我們創建了特徵向量,我們將它們加載到一些簡單的分類模型中,看看實驗結果。 我們選擇隨機森林和XGBoost基於我們的特徵工程創建一些模型。
from sklearn import cross_validation
from sklearn.cross_validation import StratifiedKFold as KFold
from sklearn.metrics import classification_report
from sklearn.ensemble import RandomForestClassifier as RF
import xgboost as xgb
X = np.load("dataX.npy")
Y = np.load("dataY.npy")
kf = KFold(Y, n_folds=3)
y_pred = Y * 0
for train, test in kf:
X_train, X_test, y_train, y_test = X[train,:], X[test,:], Y[train], Y[test]
clf = RF(n_estimators=100, n_jobs=3)
clf.fit(X_train, y_train)
y_pred[test] = clf.predict(X_test)
print classification_report(Y, y_pred, target_names=["No Cancer", "Cancer"])
print("logloss",logloss(Y, y_pred))
# All Cancer
print "Predicting all positive"
y_pred = np.ones(Y.shape)
print classification_report(Y, y_pred, target_names=["No Cancer", "Cancer"])
print("logloss",logloss(Y, y_pred))
# No Cancer
print "Predicting all negative"
y_pred = Y*0
print classification_report(Y, y_pred, target_names=["No Cancer", "Cancer"])
print("logloss",logloss(Y, y_pred))
# try XGBoost
print ("XGBoost")
kf = KFold(Y, n_folds=3)
y_pred = Y * 0
for train, test in kf:
X_train, X_test, y_train, y_test = X[train,:], X[test,:], Y[train], Y[test]
clf = xgb.XGBClassifier(objective="binary:logistic")
clf.fit(X_train, y_train)
y_pred[test] = clf.predict(X_test)
print classification_report(Y, y_pred, target_names=["No Cancer", "Cancer"])
print("logloss",logloss(Y, y_pred))
結果顯示了建模的效果
Random Forest precision recall f1-score support
No Cancer 0.77 0.95 0.85 219 Cancer 0.08 0.02 0.03 64
avg / total 0.61 0.73 0.66 283
('logloss', 9.1534198755740697) Predicting all positive precision recall f1-score support
No Cancer 0.00 0.00 0.00 219 Cancer 0.23 1.00 0.37 64
avg / total 0.05 0.23 0.08 283
('logloss', 26.728505803260379) Predicting all negative precision recall f1-score support
No Cancer 0.77 1.00 0.87 219 Cancer 0.00 0.00 0.00 64
avg / total 0.60 0.77 0.68 283
('logloss', 7.8108893613932242) XGBoost precision recall f1-score support
No Cancer 0.77 0.92 0.84 219 Cancer 0.18 0.06 0.09 64
avg / total 0.64 0.72 0.67 283
('logloss', 9.5195722669850706)
我們比較隨機森林,XGBoost的結果,以及預測患有癌症和預測不患有癌症的兩個模型的結果。結果:
Random Forest
precision recall f1-score support
No Cancer 0.81 0.97 0.88 463
Cancer 0.13 0.02 0.03 107
avg / total 0.68 0.79 0.72 570
('logloss', 7.150150893624649)
XGBoost
precision recall f1-score support
No Cancer 0.83 0.86 0.84 463
Cancer 0.27 0.21 0.24 107
avg / total 0.72 0.74 0.73 570
('logloss', 8.9074570257718957)
Predicting all positive
precision recall f1-score support
No Cancer 0.00 0.00 0.00 463
Cancer 0.19 1.00 0.32 107
avg / total 0.04 0.19 0.06 570
('logloss', 28.055831025357818)
Predicting all negative
precision recall f1-score support
No Cancer 0.81 1.00 0.90 463
Cancer 0.00 0.00 0.00 107
avg / total 0.66 0.81 0.73 570
('logloss', 6.4835948671148085)
下一步如何改進?
我們爲您提供了一個解決這個問題的框架,將基於深度學習的分割方法與舊的計算機視覺方法結合在一起。 根據這些內容您可以通過更多數據或其他預處理方法來改進u-net模型。 分類片段可以由另一個卷積網絡替代,我們普遍將圖像作爲獨立的2D切片來處理,或許您可以考慮更多地利用結節的3d性質。