Março de 2018

Volume 33 – Número 3

Execução de Teste: Classificação Binária Neural Usando CNTK

Por James McCaffrey

James McCaffreyO objetivo de um problema de classificação binária é fazer uma previsão de onde o valor a prever pode assumir um dos dois valores possíveis. Por exemplo, talvez você queira prever se um paciente hospitalar tem uma doença coronária ou não, com base em variáveis de previsão, como idade, pressão sanguínea, gênero e assim por diante. Há muitas técnicas que podem ser usadas para lidar com os problemas de uma classificação binária. Neste artigo, explicarei como usar a biblioteca CNTK (Microsoft Cognitive Toolkit) para criar um modelo de classificação binária da rede neural.

Veja a Figura 1 para saber o que será mostrado neste artigo. O programa de demonstração cria um modelo de previsão para o conjunto de dados Cleveland Heart Disease. O conjunto de dados tem 297 itens. Cada item tem 13 variáveis de previsão: idade, gênero, tipo de dor, pressão sanguínea, colesterol, açúcar no sangue, ECG, frequência cardíaca, angina, depressão do segmento ST, redução do segmento ST, número de vasos e tálio. O valor a ser previsto é a presença ou a ausência de doença cardíaca.

Classificação binária usando uma rede neural CNTK
Figura 1 Classificação binária usando uma rede neural CNTK

Nos bastidores, os dados brutos foram normalizados e codificados, resultando em 18 variáveis de previsão. A demonstração cria uma rede neural com 18 nós de entrada, 20 nós de processamento ocultos e dois nós de saída. O modelo de rede neural é treinado usando a queda aleatória de gradiente com uma taxa de aprendizagem definida como 0,005 e um tamanho de mini-lote 10.

Durante o treinamento, a perda/erro médio e a precisão média de classificação nos 10 itens atuais são exibidas a cada 500 interações. Você pode ver que, em geral, a perda/erro foi gradualmente reduzida e a precisão ampliada para mais de 5.000 interações. Após o treinamento, a precisão da classificação do modelo em todos os 297 itens de dados foi calculada em 84,18% (250 corretos, 47 incorretos).

Este artigo pressupõe que você tenha habilidades de programação que sejam pelo menos intermediárias, mesmo que não saiba muito sobre CNTK ou redes neurais. A demonstração é codificada usando Python, mas mesmo se você não conhecer o Python, deverá ser capaz de acompanhar sem muita dificuldade. O código-fonte do programa de demonstração é apresentado por completo neste artigo. O arquivo de dados usado está disponível no download complementar.

Compreendendo os dados

Existem diversas versões do conjunto de dados Cleveland Heart Disease em bit.ly/2EL9Leo. A demonstração usa a versão processada, que tem 13 das 76 variáveis de previsão originais. Os dados brutos têm 303 itens e se parecem com:

[001] 63.0,1.0,1.0,145.0,233.0,1.0,2.0,150.0,0.0,2.3,3.0,0.0,6.0,0
[002] 67.0,1.0,4.0,160.0,286.0,0.0,2.0,108.0,1.0,1.5,2.0,3.0,3.0,2
[003] 67.0,1.0,4.0,120.0,229.0,0.0,2.0,129.0,1.0,2.6,2.0,2.0,7.0,1
...
[302] 57.0,0.0,2.0,130.0,236.0,0.0,2.0,174.0,0.0,0.0,2.0,1.0,3.0,1
[303] 38.0,1.0,3.0,138.0,175.0,0.0,0.0,173.0,0.0,0.0,1.0,?,3.0,0

Os 13 primeiros valores em cada linha são previsões. O último item de cada linha é um valor entre 0 e 4, onde 0 significa a ausência de doença cardíaca e 1, 2, 3 ou 4 significam a presença de doença cardíaca. Em geral, a parte mais demorada da maioria dos cenários de machine learning é a preparação de seus dados. Como há mais de duas variáveis de previsão, não é possível criar um grafo dos dados brutos. Mas você pode ter uma vaga ideia do problema ao examinar somente a idade e a pressão sanguínea, como mostrado na Figura 2.

Dados brutos parciais do Cleveland Heart Disease
Figura 2 Dados brutos parciais do Cleveland Heart Disease

A primeira etapa é lidar com dados ausentes — observe o “?” no item [303]. Como há somente seis itens com valores ausentes, esses seis itens foram simplesmente ignorados, deixando 297 itens.

A próxima etapa é normalizar os valores de previsão numéricos, como a idade na primeira coluna. A demonstração usou a normalização mín-máx, onde o valor em uma coluna é substituído por (valor - mín) / (máx - mín). Por exemplo, o valor mínimo de idade é 29 e o máximo é 77; portanto, o primeiro valor de idade, 63, é normalizado como (63 - 29) / (77 - 29) = 34/48 = 0,70833.

A próxima etapa é codificar os valores de previsão categóricos, como gênero (0 = feminino, 1 = masculino) na segunda coluna e tipo de dor (1, 2, 3, 4) na terceira coluna. A demonstração usou a codificação 1-de-(N-1) e, portanto, o gênero é codificado como feminino = -1, masculino = +1. O tipo de dor é codificado como 1 = (1, 0, 0), 2 = (0, 1, 0), 3 = (0, 0, 1), 4 = (-1, -1, -1).

A última etapa é codificar o valor a ser previsto. Ao usar uma rede neural para a classificação binária, você pode codificar o valor a ser previsto usando somente um nó com um valor 0 ou 1, ou pode usar dois nós com os valores (0, 1) ou (1, 0). Por um motivo que explicarei em breve, ao usar o CNTK, é muito melhor usar a técnica de dois nós. Dessa forma, 0 (nenhuma doença cardíaca) foi codificado como (0,1) e os valores de 1 a 4 (doença cardíaca) foram codificados como (1,0).

Os dados normalizados e codificados finais foram delimitados por tabulação e se parecem com:

|symptoms  0.70833  1  1  0  0  0.48113 ... |disease  0  1
|symptoms  0.79167  1 -1 -1 -1  0.62264 ... |disease  1  0
...

As marcas “|symptoms” e “|disease” foram inseridas para que os dados pudessem ser facilmente lidos por um objeto de leitor de dados CNTK.

O programa de demonstração

O programa de demonstração completo, com algumas pequenas edições para economizar espaço, é apresentado na Figura 3. Todas as verificações de erros normais foram removidas. Eu recuo dois caracteres de espaço em vez dos quatro usuais para economizar espaço e por preferência pessoal. O caractere “\” é usado pelo Python para continuar a linha.

Figura 3 Estrutura do programa de demonstração

# cleveland_bnn.py
# CNTK 2.3 with Anaconda 4.1.1 (Python 3.5, NumPy 1.11.1)
import numpy as np
import cntk as C
def create_reader(path, input_dim, output_dim, rnd_order, sweeps):
  x_strm = C.io.StreamDef(field='symptoms', shape=input_dim,
   is_sparse=False)
  y_strm = C.io.StreamDef(field='disease', shape=output_dim,
    is_sparse=False)
  streams = C.io.StreamDefs(x_src=x_strm, y_src=y_strm)
  deserial = C.io.CTFDeserializer(path, streams)
  mb_src = C.io.MinibatchSource(deserial, randomize=rnd_order, \
    max_sweeps=sweeps)
  return mb_src
# ===================================================================
def main():
  print("\nBegin binary classification (two-node technique) \n")
  print("Using CNTK version = " + str(C.__version__) + "\n")
  input_dim = 18
  hidden_dim = 20
  output_dim = 2
  train_file = ".\\Data\\cleveland_cntk_twonode.txt"
  # 1. create network
  X = C.ops.input_variable(input_dim, np.float32)
  Y = C.ops.input_variable(output_dim, np.float32)
  print("Creating a 18-20-2 tanh-softmax NN ")
  with C.layers.default_options(init=C.initializer.uniform(scale=0.01,\
    seed=1)):
    hLayer = C.layers.Dense(hidden_dim, activation=C.ops.tanh,
      name='hidLayer')(X) 
    oLayer = C.layers.Dense(output_dim, activation=None,
     name='outLayer')(hLayer)
  nnet = oLayer
  model = C.ops.softmax(nnet)
  # 2. create learner and trainer
  print("Creating a cross entropy batch=10 SGD LR=0.005 Trainer ")
  tr_loss = C.cross_entropy_with_softmax(nnet, Y)
  tr_clas = C.classification_error(nnet, Y)
  max_iter = 5000
  batch_size = 10
  learn_rate = 0.005
  learner = C.sgd(nnet.parameters, learn_rate)
  trainer = C.Trainer(nnet, (tr_loss, tr_clas), [learner])
  # 3. create reader for train data
  rdr = create_reader(train_file, input_dim, output_dim,
    rnd_order=True, sweeps=C.io.INFINITELY_REPEAT)
  heart_input_map = {
    X : rdr.streams.x_src,
    Y : rdr.streams.y_src
  }
  # 4. train
  print("\nStarting training \n")
  for i in range(0, max_iter):
    curr_batch = rdr.next_minibatch(batch_size, \
      input_map=heart_input_map)
    trainer.train_minibatch(curr_batch)
    if i % int(max_iter/10) == 0:
      mcee = trainer.previous_minibatch_loss_average
      macc = (1.0 - trainer.previous_minibatch_evaluation_average) \
        * 100
      print("batch %4d: mean loss = %0.4f, accuracy = %0.2f%% " \
        % (i, mcee, macc))
  print("\nTraining complete")
  # 5. evaluate model using all data
  print("\nEvaluating accuracy using built-in test_minibatch() \n")
  rdr = create_reader(train_file, input_dim, output_dim,
    rnd_order=False, sweeps=1)
  heart_input_map = {
    X : rdr.streams.x_src,
    Y : rdr.streams.y_src
  }
  num_test = 297
  all_test = rdr.next_minibatch(num_test, input_map=heart_input_map)
  acc = (1.0 - trainer.test_minibatch(all_test)) * 100
  print("Classification accuracy on the %d data items = %0.2f%%" \
    % (num_test,acc))
  # (could save model here)
  # (use trained model to make prediction)
  print("\nEnd Cleveland Heart Disease classification ")
# ===================================================================
if __name__ == "__main__":
  main()

A demonstração cleveland_bnn.py tem uma função auxiliar, create_reader. Toda a lógica de controle está em uma única função main. Como o CNTK é jovem e está sob grande desenvolvimento, é uma boa ideia adicionar um comentário detalhando qual versão está sendo usada (neste caso, a 2.3).

A instalação do CNTK pode ser um pouco complicada. Primeiro, você instala a distribuição Anaconda do Python, que contém o intérprete Python necessário, os pacotes necessários, como NumPy e SciPy, e utilitários úteis, como o pip. Eu usei a Anaconda3 4.1.1 de 64 bits, que inclui o Python 3.5. Depois de instalar a Anaconda, você instala o CNTK como um pacote do Python, e não como um sistema autônomo, usando o utilitário pip. De um shell comum, o comando que usei foi:

>pip install https://cntk.ai/PythonWheel/CPU-Only/cntk-2.3-cp35-cp35m-win_amd64.whl

Quase todas as falhas na instalação do CNTK que já vi aconteceram por causa de incompatibilidades de versão entre a Anaconda e o CNTK.

A demonstração começa pela preparação para criar a rede neural:

input_dim = 18
hidden_dim = 20
output_dim = 2
train_file = ".\\Data\\cleveland_cntk_twonode.txt"
X = C.ops.input_variable(input_dim, np.float32)
Y = C.ops.input_variable(output_dim, np.float32)

O número de nós de entrada e saída é determinado por seus dados , mas o número de nós de processamento ocultos é um parâmetro livre e deve ser determinado através de tentativa e erro. O uso de variáveis de 32 bits é normal para redes neurais, já que a precisão obtida pelo uso de 64 bits não vale a redução de desempenho incorrida.

A rede é criada assim:

with C.layers.default_options(init=C.initializer.uniform(scale=0.01,\
  seed=1)):
  hLayer = C.layers.Dense(hidden_dim, activation=C.ops.tanh,
    name='hidLayer')(X) 
  oLayer = C.layers.Dense(output_dim, activation=None,
   name='outLayer')(hLayer)
nnet = oLayer
model = C.ops.softmax(nnet)

O Python com instrução é um atalho sintático para aplicar um conjunto de argumentos comuns a várias funções. A demonstração usa a ativação tanh nos nós de camada ocultos; uma alternativa comum é a função sigmoid. Observe que não há ativação aplicada aos nós de saída. Essa é uma peculiaridade do CNTK, já que a função de treinamento do CNTK espera valores brutos e desativados. O objeto nnet é somente um alias de conveniência. O objeto model tem a ativação softmax para poder ser usado após o treinamento para fazer previsões. Como o Python atribui por referência, o treinamento do objeto nnet também treina o objeto mode.

Treinando a rede neural

A rede neural é preparada para treinamento com:

tr_loss = C.cross_entropy_with_softmax(nnet, Y)
tr_clas = C.classification_error(nnet, Y)
max_iter = 5000
batch_size = 10
learn_rate = 0.005
learner = C.sgd(nnet.parameters, learn_rate)
trainer = C.Trainer(nnet, (tr_loss, tr_clas), [learner])

O objeto tr_loss (“perda de treinamento”) diz ao CNTK como medir o erro ao treinar. Uma alternativa para a entropia cruzada com softmax é o erro quadrático. O objeto tr_clas (“erro de classificação de treinamento”) pode ser usado para calcular automaticamente a porcentagem de previsões incorretas durante ou após o treinamento.

Os valores do número máximo de iterações de treinamento, o número de itens em um lote para treinar em uma ocasião, e a taxa de aprendizagem, são parâmetros livres que devem ser determinados por tentativa e erro. Você pode pensar no objeto learner como um algoritmo, e o objeto treinar como o objeto que usa o learner para encontrar bons valores para os pesos e desvios da rede neural.

Um objeto reader é criado com estas instruções:

rdr = create_reader(train_file, input_dim, output_dim,
  rnd_order=True, sweeps=C.io.INFINITELY_REPEAT)
heart_input_map = {
  X : rdr.streams.x_src,
  Y : rdr.streams.y_src
}

Se você examinar a definição create_reader na Figura 3, verá que ela especifica os nomes de marcas (“symptoms” e “disease”) usados no arquivo de dados. Você pode considerar create_reader e o código para criar um objeto reader como código de texto clichê para problemas de classificação binária neural. Tudo o que você precisa alterar é os nomes de marca e o nome do dicionário de mapeamento (heart_input_map).

Depois de tudo preparado, o treinamento é realizado desta forma:

for i in range(0, max_iter):
  curr_batch = rdr.next_minibatch(batch_size, \
    input_map=heart_input_map)
  trainer.train_minibatch(curr_batch)
  if i % int(max_iter/10) == 0:
    mcee = trainer.previous_minibatch_loss_average
    macc = (1.0 - trainer.previous_minibatch_evaluation_average) \
      * 100
    print("batch %4d: mean loss = %0.4f, accuracy = %0.2f%% " \
      % (i, mcee, macc))

Uma alternativa ao treinamento com um número fixo de iterações é interromper o treinamento quando a perda/erro cair para aquém de um limite. É importante exibir a perda/erro durante o treinamento porque a falha de treinamento é a regra, em vez da exceção. O erro de entropia cruzada é um pouco difícil de interpretar de forma direta, mas você deseja ver valores que tendem a ficar menores. Em vez de exibir a perda/erro média de classificação, a demonstração calcula e imprime a precisão média da classificação, que é uma métrica mais natural, na minha opinião.

Avaliação e uso do modelo

Após o treinamento da rede, normalmente você desejará determinar a perda/erro e a precisão da classificação para todo o conjunto de dados usado para o treinamento. A demonstração avalia a precisão geral da classificação com:

rdr = create_reader(train_file, input_dim, output_dim,
  rnd_order=False, sweeps=1)
heart_input_map = {
  X : rdr.streams.x_src,
  Y : rdr.streams.y_src
}
num_test = 297
all_test = rdr.next_minibatch(num_test, input_map=heart_input_map)
acc = (1.0 - trainer.test_minibatch(all_test)) * 100
print("Classification accuracy on the %d data items = %0.2f%%" \
  % (num_test,acc))

Um novo leitor (reader) de dados é criado. Observe que, ao contrário do reader usado para treinamento, o novo reader não atravessa os dados em ordem aleatória, e o número de limpezas é definido como 1. O objeto de dicionário heart_input_map é recriado. Um erro comum é tentar usar o objeto original — mas o objeto rdr foi alterado e, portanto, você precisa recriar o mapeamento. A função test_minibatch retorna o erro de classificação média para seu argumento mini-batch que, neste caso, é o conjunto de dados inteiro.

O programa de demonstração não calcula a perda/erro do conjunto de dados inteiro. Você pode usar a função previous_minibatch_loss_average, mas precisa ter cuidado para não realizar uma iteração de treinamento adicional, o que mudaria a rede.

Após o treinamento, ou durante o treinamento, normalmente você desejará salvar o modelo. No CNTK, o salvamento é assim:

mdl_name = ".\\Models\\cleveland_bnn.model"
model.save(mdl_name)

Isso salva usando o formato padrão do CNTK v2. Uma alternativa é usar o formato do Open Neural Network Exchange (ONNX). Observe que, geralmente, você desejará salvar o objeto model (com a ativação softmax), em vez do objeto nnet.

De um programa diferente, um modelo salvo poderia ser carregado na memória junto com as linhas de:

mdl_name = ".\\Models\\cleveland_bnn.model"
model = C.ops.functions.Function.load(mdl_name)

Após o carregamento, o modelo poderá ser usado como se tivesse acabado de ser treinado.

O programa de demonstração não usa o modelo treinado para fazer uma previsão. Você pode escrever código desta forma:

unknown = np.array([0.5555, -1, ... ], dtype=np.float32)
predicted = model.eval(unknown)

O resultado retornado para a variável prevista seria uma matriz 1x2 com valores cuja soma é 1,0, por exemplo, [[0,2500, 0,7500]]. Como o segundo valor é maior, o resultado seria mapeado para (0, 1), que, por sua vez, seria mapeado para “nenhuma doença”.

Conclusão

A maioria das bibliotecas de códigos de aprendizagem profunda realizam a classificação binária de rede neural usando a técnica de um nó. O uso dessa abordagem, os valores da variável para a previsão são codificados como 0 ou 1. A dimensão de saída seria definida como 1 em vez de 2. Você teria de usar o erro de entropia cruzada binária em vez do erro de entropia cruzada normal. O CNTK não tem uma função de erro de classificação interna que funcione com um nó e, portanto, você teria de implementar sua própria função do zero. Durante o treinamento, menos informações normalmente são obtidas em cada iteração (embora o treinamento seja um pouco mais rápido) e, portanto, normalmente você teria de treinar um modelo de um nó para mais iterações do que ao usar a técnica de dois nós. Por esses motivos, prefiro usar a técnica de dois nós para a classificação binária neural.


Dr. James McCaffreytrabalha para a Microsoft Research em Redmond, Washington. Ele trabalhou em vários produtos da Microsoft, incluindo Internet Explorer e Bing. Entre em contato com o Dr. McCaffrey pelo email jamccaff@microsoft.com.

Agradecemos aos seguintes especialistas técnicos da Microsoft pela revisão deste artigo: Chris Lee, Ricky Loynd, Kenneth Tran


Discuta esse artigo no fórum do MSDN Magazine