Fevereiro de 2018

Volume 33 – Número 2

Machine Learning - Classificadores de rede neural profunda usando CNTK

Por James McCaffrey

A biblioteca do Kit de Ferramentas Cognitivas da Microsoft (CNTK) é um conjunto poderoso de funções que lhe permite criar sistemas de previsão de machine learning (ML). Forneci uma introdução da versão 2 na edição de julho de 2017 (msdn.com/magazine/mt784662). Neste artigo, eu explico como usar o CNTK para criar um classificador de rede neural profundo. Uma boa maneira de saber o rumo deste artigo é observando a captura de tela na Figura 1.

Demonstração de previsão da variedade de semente de trigo
Figura 1 Demonstração de previsão da variedade de semente de trigo

A biblioteca CNTK é escrita em C++ por questões de desempenho, mas ma maneira mais comum de chamar as funções da biblioteca é usar a API da linguagem Python CNTK. Eu invoquei o programa de demonstração enviando o seguinte comando em um shell de comando comum do Windows 10:

> python seeds_dnn.py

O objetivo do programa de demonstração é criar uma rede neural profunda que possa prever a variedade de uma semente de trigo. Nos bastidores, o programa de demonstração usa um conjunto de dados de treinamento que se parece com o seguinte:

|properties 15.26 14.84 ... 5.22 |variety 1 0 0
|properties 14.88 14.57 ... 4.95 |variety 1 0 0
...
|properties 17.63 15.98 ... 6.06 |variety 0 1 0
...
|properties 10.59 12.41 ... 4.79 |variety 0 0 1

Os dados de treinamento têm 150 itens. Cada linha representa uma das três variedades de semente de trigo: “Kama,” “Rosa” ou “Canadense”. Os sete primeiros valores numéricos em cada linha são os valores da previsão, normalmente chamados atributos ou recursos na terminologia de machine learning. As previsões consistem em área, perímetro, compactação, comprimento, largura, coeficiente de assimetria e comprimento do sulco da semente. O item a ser previsto (normalmente chamado de classe ou rótulo) preenche as últimas três colunas e é codificado como 1 0 0 para Kama, 0 1 0 para Rosa e 0 0 1 para Canadense.

O programa de demonstração também usa um conjunto de dados de 60 itens, 20 de cada variedade de semente. Os dados do teste têm o mesmo formato que os dados de treinamento.

O programa de demonstração cria uma rede neural profunda de 7-(4-4-4)-3. A rede é ilustrada na Figura 2. Existem sete nós de entrada (um para cada valor de previsão), três camadas escondidas, cada qual com quatro nós de processamento, e três nós de saída que correspondem às três variedades possíveis de semente de trigo codificadas.

Estrutura da rede neural profunda
Figura 2 Estrutura da rede neural profunda

O programa de demonstração treina a rede usando 5.000 lotes de 10 itens cada, usando o algoritmo do descendente de gradiente aleatório (SGD). Após o modelo de previsão ter sido treinado, ele é aplicado ao conjunto de dados de teste de 60 itens. O modelo obteve 78,33% de precisão, o que significa que ele previu corretamente 47 dos 60 itens de teste.

O programa de demonstração conclui fazendo uma previsão para uma semente de trigo desconhecida. Os sete valores de entrada são (17,6, 15,9, 0,8, 6,2, 3,5, 4,1, 6,1). Os valores brutos calculados do nó de saída são (1,0530, 2,5276, -3,6578) e os valores de probabilidade do nó de saída associado são (0,1859, 0,8124, 0,0017). Como o valor médio é o maior, a saída mapeia para (0, 1, 0), que é variedade Rosa.

 Este artigo presume que você tenha conhecimento intermediário ou superior em programação com uma linguagem da família C e familiaridade básica com redes neurais. Porém, independentemente de seus conhecimentos, você deve conseguir acompanhar as instruções sem grandes complicações. O código-fonte completo do programa de demonstração seeds_dnn.py é apresentado neste artigo. O código, e os arquivos de treinamento e de dados de teste associados, também estão disponíveis no download do arquivo que acompanha este artigo.

Instalando o CNTK v2

Como o CNTK v2 é relativamente novo, você poderá não estar familiarizado com o processo de instalação. Resumidamente, primeiro você instala uma distribuição de linguagem Python (eu fortemente recomendo a distribuição Anaconda) que contém a linguagem Python principal e os pacotes Python necessários e, em seguida, você instala o CNTK como um pacote Python adicional. Em outras palavras, o CNTK não é uma instalação autônoma.

No momento em que escrevi este artigo, a versão atual do CNTK é a v2.3. Como o CNTK está passando por um grande processo de desenvolvimento, é provável que já exista uma nova versão quando você ler este artigo. Eu usei a versão de distribuição Anaconda 4.1.1 (que contém o Python versão 3.5.2, NumPy versão 1.11.1 e SciPy versão 0.17.1). Depois de instalar o Anaconda, instalei a versão somente para CPU do CNTK usando o programa utilitário pip. Instalar o CNTK pode ser um pouco complexo se você não tiver cuidado com a compatibilidade da versão, mas a documentação do CNTK descreve o processo de instalação detalhadamente.

Compreendendo os dados

A criação da maioria dos sistemas de machine learning começa com o processo demorado e muitas vezes chato de configuração dos arquivos de dados de teste e treinamento. O conjunto de dados das sementes de trigo pode ser encontrado em bit.ly/2idhoRK. Os dados brutos delimitados por guias de 210 itens se parecem com isto:

14.11  14.1   0.8911  5.42  3.302  2.7  5      1
16.63  15.46  0.8747  6.053 3.465  2.04 5.877  1

Eu escrevi o programa utilitário para gerar um arquivo em um formato que pode ser facilmente manipulado pelo CNTK. O arquivo de 210 itens resultante tem esta aparência:

|properties 14.1100 14.1000 ... 5.0000 |variety 1 0 0
|properties 16.6300 15.4600 ... 5.8770 |variety 1 0 0

O programa utilitário adicionou uma marca "|properties" para identificar o local dos recursos, e uma marca "|variety" para identificar o local da classe a ser prevista. Os valores de classe brutos foram codificados como 1 de N (às vezes chamados em inglês de one-hot encoding), as guias foram substituídas por caracteres de espaço em branco, e todos os valores de previsão foram formatados para exatamente quatro casas decimais.

Na maioria das situações, você desejará normalizar os valores de previsão numéricos para que todos eles tenham aproximadamente o mesmo intervalo. Eu não normalizei esses dados para manter este artigo um pouco mais simples. As duas formas comuns de normalização são a de pontuação z e a de mínimo e máximo. Em geral, em cenários que não são de demonstração, eu deveria normalizar os valores de previsão.

Em seguida, escrevi outro programa utilitário que usou o arquivo de dados de 210 itens no formato CNTK e, em seguida, usei o arquivo para gerar um arquivo de dados de treinamento de 150 itens chamado seeds_train_data.txt (os primeiros 50 de cada variedade) e um arquivo de teste de 60 itens chamado seeds_test_data.txt (os últimos 20 de cada variedade).

Como existem sete variáveis de previsão, não é possível fazer um gráfico completo dos dados. Mas você pode obter uma ideia aproximada da estrutura dos dados pelo gráfico dos dados parciais na Figura 3. Eu usei apenas o perímetro da semente e os valores de previsão de compactação do conjunto de dados do teste de 60 itens.

Gráfico parcial dos dados de teste
Figura 3 Gráfico parcial dos dados de teste

Programa de demonstração da rede neural profunda

Eu usei o bloco de notas para escrever o programa de demonstração. Eu gosto do bloco de notas, mas a maioria dos meus colegas preferem usar um dos muitos editores excelentes do Python disponíveis. O editor do Visual Studio Code com o suplemento da linguagem Python é especialmente interessante. O código-fonte do programa de demonstração completo, com algumas pequenas edições para economizar espaço, é apresentado na Figura 4. Observe que o caractere de barra invertida é usado pelo Python para continuar a linha.

Figura 4 Programa completo de demonstração do classificador de semente

# seeds_dnn.py
# classify wheat seed variety
import numpy as np
import cntk as C
def create_reader(path, is_training, input_dim, output_dim):
  strm_x = C.io.StreamDef(field='properties',
    shape=input_dim, is_sparse=False)
  strm_y = C.io.StreamDef(field='variety',
    shape=output_dim, is_sparse=False)
  streams = C.io.StreamDefs(x_src=strm_x,
    y_src=strm_y)
  deserial = C.io.CTFDeserializer(path, streams)
  sweeps = C.io.INFINITELY_REPEAT if is_training else 1
  mb_source = C.io.MinibatchSource(deserial,
    randomize=is_training, max_sweeps=sweeps)
  return mb_source
def main():
  print("\nBegin wheat seed classification demo  \n")
  print("Using CNTK verson = " + str(C.__version__) + "\n")
  input_dim = 7
  hidden_dim = 4
  output_dim = 3
  train_file = ".\\Data\\seeds_train_data.txt"
  test_file = ".\\Data\\seeds_test_data.txt"
  # 1. create network and model
  X = C.ops.input_variable(input_dim, np.float32)
  Y = C.ops.input_variable(output_dim, np.float32)
  print("Creating a 7-(4-4-4)-3 tanh softmax NN for seed data ")
  with C.layers.default_options(init= \
    C.initializer.normal(scale=0.1, seed=2)):
    h1 = C.layers.Dense(hidden_dim, activation=C.ops.tanh,
      name='hidLayer1')(X)
    h2 = C.layers.Dense(hidden_dim, activation=C.ops.tanh,
      name='hidLayer2')(h1)
    h3 = C.layers.Dense(hidden_dim, activation=C.ops.tanh,
      name='hidLayer3')(h2)
    oLayer = C.layers.Dense(output_dim, activation=None,
      name='outLayer')(h3)
  nnet = oLayer
  model = C.softmax(nnet)
  # 2. create learner and trainer
  print("Creating a cross entropy, SGD with LR=0.01, \
    batch=10 Trainer \n")
  tr_loss = C.cross_entropy_with_softmax(nnet, Y)
  tr_clas = C.classification_error(nnet, Y)
  learn_rate = 0.01
  learner = C.sgd(nnet.parameters, learn_rate)
  trainer = C.Trainer(nnet, (tr_loss, tr_clas), [learner])
  max_iter = 5000  # maximum training iterations
  batch_size = 10   # mini-batch size
  # 3. create data reader
  rdr = create_reader(train_file, True, input_dim,
    output_dim)
  my_input_map = {
    X : rdr.streams.x_src,
    Y : rdr.streams.y_src
  }
  # 4. train
  print("Starting training \n")
  for i in range(0, max_iter):
    curr_batch = rdr.next_minibatch(batch_size,
      input_map=my_input_map)
    trainer.train_minibatch(curr_batch)
    if i % 1000 == 0:
      mcee = trainer.previous_minibatch_loss_average
      pmea = trainer.previous_minibatch_evaluation_average
      macc = (1.0 - pmea) * 100
      print("batch %6d: mean loss = %0.4f, \
        mean accuracy = %0.2f%% " % (i,mcee, macc))
  print("\nTraining complete")
  # 5. evaluate model on the test data
  print("\nEvaluating test data \n")
  rdr = create_reader(test_file, False, input_dim, output_dim)
  my_input_map = {
    X : rdr.streams.x_src,
    Y : rdr.streams.y_src
  }
  numTest = 60
  allTest = rdr.next_minibatch(numTest, input_map=my_input_map)
  acc = (1.0 - trainer.test_minibatch(allTest)) * 100
  print("Classification accuracy on the \
    60 test items = %0.2f%%" % acc)
  # (could save model here)
  # 6. use trained model to make prediction
  np.set_printoptions(precision = 4)
  unknown = np.array([[17.6, 15.9, 0.8, 6.2, 3.5, 4.1, 6.1]],
    dtype=np.float32)
  print("\nPredicting variety for (non-normalized) seed features: ")
  print(unknown[0])
  raw_out = nnet.eval({X: unknown})
  print("\nRaw output values are: ")
  for i in range(len(raw_out[0])):
    print("%0.4f " % raw_out[0][i])
  pred_prob = model.eval({X: unknown})
  print("\nPrediction probabilities are: ")
  for i in range(len(pred_prob[0])):
    print("%0.4f " % pred_prob[0][i])
  print("\nEnd demo \n ")
# main()
if __name__ == "__main__":
  main()

A demonstração começa com a importação dos pacotes NumPy e CNTK especificados, e com a atribuição de aliases de atalho do np e C a eles. A Função create_reader é um auxiliar definido pelo programa que pode ser usado para ler dados de treinamento (se o parâmetro is_training for definido como True) ou os dados de teste (se is_training for definido como False).

Você pode considerar a função create_reader como um código clichê para problemas de classificação neural. As únicas coisas que você precisará alterar na maioria das situações são os dois valores da cadeira de caracteres dos argumentos de campo nas chamadas à função StreamDef “properties” e “varieties” na demonstração.

Toda a lógica de controle do programa está contida em uma única função principal. Todo o código de verificação de erros normais foi removido para manter o tamanho da demonstração pequeno e para ajudar a manter as principais ideias claras. Observe que eu recuei dois espaços em vez dos habituais quatro espaços para economizar espaço.

Criando a rede e o modelo

A principal função começa com a configuração das dimensões da arquitetura da rede neural:

def main():
  print("Begin wheat seed classification demo")
  print("Using CNTK verson = " + str(C.__version__) )
  input_dim = 7
  hidden_dim = 4
  output_dim = 3
...

Como o CNTK está passando por um rápido desenvolvimento, é uma boa ideia imprimir ou comentar a versão que está sendo usada. A demonstração tem três camadas ocultas, todas elas com quatro nós cada. O número de camadas ocultas, e o número de nós em cada camada, deve ser determinado por tentativa e erro. Você pode ter um número diferente de nós em cada camada se quiser. Por exemplo, hidden_dim = [10, 8, 10, 12] corresponderia a uma rede profunda com quatro camadas ocultas, com 10, 8, 10 e 12 nós respectivamente.

Em seguida, o local do treinamento e dos arquivos de dados de teste é especificado e os vetores de entrada e saída da rede são criados:

train_file = ".\\Data\\seeds_train_data.txt"
test_file = ".\\Data\\seeds_test_data.txt"
# 1. create network and model
X = C.ops.input_variable(input_dim, np.float32)
Y = C.ops.input_variable(output_dim, np.float32)

Observe que eu coloco os arquivos de treinamento e de teste em um subdiretório de dados separado, que é uma prática comum porque, frequentemente, você tem muitos arquivos de dados diferentes durante a criação do modelo. Usar o tipo de dados np.float32 é muito mais comum do que usar o tipo np.float64 porque a precisão adicional obtida usando 64 bits normalmente não compensa aquilo que você perde em desempenho.

Em seguida, a rede é criada:

print("Creating a 7-(4-4-4)-3 NN for seed data ")
with C.layers.default_options(init= \
  C.initializer.normal(scale=0.1, seed=2)):
  h1 = C.layers.Dense(hidden_dim,
    activation=C.ops.tanh, name='hidLayer1')(X)
  h2 = C.layers.Dense(hidden_dim, activation=C.ops.tanh,
    name='hidLayer2')(h1)
  h3 = C.layers.Dense(hidden_dim, activation=C.ops.tanh,
    name='hidLayer3')(h2)
  oLayer = C.layers.Dense(output_dim, activation=None,
    name='outLayer')(h3)
nnet = oLayer
model = C.softmax(nnet)

Muita coisa acontece aqui. O Python com instrução é uma sintaxe de atalho para aplicar um conjunto de valores comuns a várias camadas de uma rede. Aqui, todos os pesos são dados a um valor aleatório Gaussiano (curva em forma de sino) com um desvio padrão de 0,1 e uma média de 0. Configurar um valor de semente garante sua capacidade de reprodução. O CNTK suporta um grande número de algoritmos de inicialização, incluindo “uniform,” “glorot,” “he” e “xavier”. As redes neurais profundas são, com frequência, surpreendentemente sensíveis à escolha do algoritmo de inicialização, por isso, quando o treinamento falha, uma das primeiras coisas a tentar é um algoritmo de inicialização alternativo.

As três camadas ocultas são definidas usando a função Dense, que tem este nome porque cada nó está totalmente conectado aos nós nas camadas anteriores e posteriores. A sintaxe usada pode ser confusa. Aqui, X atua como entrada da camada oculta h1. A camada h1 atua como entrada da camada oculta h2, e assim por diante.

Observe que a camada de saída não usa nenhuma função de ativação, por isso, os nós de saída terão valores que não necessariamente somam para 1. Se você tem experiência com outras bibliotecas de rede neural, isso exige alguma explicação. Com muitas outras bibliotecas neurais, você usaria ativação softmax na camada de saída, de forma que o valor de saída sempre some para 1 e possa ser interpretado como probabilidades. Em seguida, durante o treinamento, você usaria um erro de entropia cruzada (também chamada de perda de log), que requer um conjunto de valores que somam para 1.

Porém, surpreendentemente, o CNTK v2.3 não tem uma função de erro de entropia cruzada para o treinamento. Em vez disso, o CNTK tem uma entropia cruzada com função softmax. Isso significa que, durante o treinamento, os valores do nó são convertidos rapidamente em probabilidades usando softmax para calcular o termo do erro.

Por isso, com o CNTK, você treina um rede profunda em valores de nó de saída brutos, mas ao fazer as previsões, se quiser prever as probabilidades, como é normalmente o caso, você deve aplicar a função softmax explicitamente. A abordagem usada pela demonstração é treinar no objeto “nnet” (sem ativação na camada de saída), mas criar um objeto “model” adicional, com a softmax aplicada, para usar ao fazer as previsões.

Na verdade, agora é possível usar a ativação da softmax na camada de saída, e então usar a entropia com a softmax durante o treinamento. Esta abordagem faz com que a softmax seja aplicada duas vezes, primeiro nos valores de saída brutos e, em seguida, nos valores do nó de saída normalizado. Na verdade, embora esta abordagem funcione, devido a questões técnicas razoavelmente complexas, o treinamento não é eficiente.

O encadeamento de camadas oculta pode ser feito até certo ponto. Para redes muito profundas, o CNTK suporta uma metafunção chamada Sequential, que fornece uma sintaxe de atalho para criar redes com várias camadas. A biblioteca CNTK também tem uma função Dropout que pode ser usada para ajudar a evitar o sobreajuste do modelo. Por exemplo, para adicionar a função dropout à primeira camada oculta, você deverá modificar o código de demonstração da seguinte forma:

h1 = C.layers.Dense(hidden_dim, activation=C.ops.tanh,
  name='hidLayer1')(X)
d1 = C.layers.Dropout(0.50, name='drop1')(h1)
h2 = C.layers.Dense(hidden_dim, activation=C.ops.tanh,
  name='hidLayer2')(d1)
h3 = C.layers.Dense(hidden_dim, activation=C.ops.tanh,
  name='hidLayer3')(h2)
oLayer = C.layers.Dense(output_dim, activation=None,
  name='outLayer')(h3)

Muitos dos meus colegas preferem sempre usar Sequential, mesmo para redes neurais profundas que somente têm algumas camadas ocultas. Eu prefiro o encadeamento manual, mas isto é apenas uma questão de estilo.

Treinando a rede

Depois de criar uma rede neural e um modelo, o programa de demonstração cria um objeto Learner e um objeto Trainer:

print("Creating a Trainer \n")
tr_loss = C.cross_entropy_with_softmax(nnet, Y)
tr_clas = C.classification_error(nnet, Y)
learn_rate = 0.01
learner = C.sgd(nnet.parameters, learn_rate)
trainer = C.Trainer(nnet, (tr_loss, tr_clas), [learner])

Você pode imaginar um objeto Learner como um algoritmo e um Trainer como um objeto que usa o algoritmo Learner. O objeto tr_loss (“training loss”) define como medir erros entre os valores de saída calculados na rede e os valores de saída corretos conhecidos nos dados do treinamento. Para classificação, a entropia cruzada é quase sempre usada, mas a CNTK é compatível com várias alternativas. Com “with_softmax”, parte do nome da função indica que esta espera obter os valores do nó de saída brutos em vez dos valores normalizados com a softmax. É por isso que a camada de saída não usa uma função de ativação.

O objeto tr_clas (“training classification error”) define como o número de previsões corretas e incorretas é calculado durante o treinamento. O CNTK define uma função de biblioteca de erro de classificação (porcentagem de previsões incorretas) em vez de uma função de precisão de classificação usada por outras bibliotecas. Por isso, existem duas formas de erro sendo calculadas durante o treinamento. O erro tr_loss é usado para ajustar os pesos e os desvios. O tr_clas é usado para monitorar a precisão da previsão.

O objeto Learner usa o algoritmo SGD com uma taxa de aprendizado constante de 0,01. SGD é o algoritmo de treinamento mais simples, porém é raramente o algoritmo com o melhor desempenho. O CNTK é compatível com um grande número de algoritmos do objeto Learner, alguns dos quais são muito complexos. Como uma regra de thumb, recomendo começar com SGD e somente experimentar algoritmos mais exóticos se o treinamento falhar. O algoritmo Adam (Adam não é um acrônimo) é normalmente a minha segunda opção.

Observe a sintaxe incomum usada para criar um objeto Trainer. Os dois objetos de função são transmitidos como uma tupla do Python, indicada pelos parênteses, mas o objeto Learner é transmitido como uma lista do Python, indicada por parênteses retos. Você pode transmitir vários objetos Learner para um Trainer, embora o programa de demonstração transmita apenas um.

O código que realmente executa o treinamento é:

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

É importante monitorar o progresso do treinamento porque ele frequentemente falha. Aqui, o erro de entropia cruzada no lote que acabou de ser usado dos 10 itens de treinamento é exibido a cada 1000 iterações. A demonstração exibe a precisão de classificação média (porcentagem das previsões corretas nos 10 itens atuais), o que eu acredito ser uma métrica mais natural que um erro de classificação (porcentagem de previsões incorretas).

Salvando o modelo treinado

Como existem apenas 150 itens de treinamento, a rede neural de demonstração pode ser treinada em questão de segundos. Mas em cenários que não são de demonstração, treinar uma rede neural muito profunda pode levar horas, dias ou até mais. Após o treinamento, você desejará salvar seu modelo para não ter que treinar tudo novamente do zero. Salvar e carregar um modelo CNTK é muito fácil. Para salvar, você pode adicionar um código como este ao programa de demonstração:

mdl = ".\\Models\\seed_dnn.model"
model.save(mdl, format=C.ModelFormat.CNTKv2)

O primeiro argumento foi transmitido para a função save é apenas um filename, possivelmente incluindo um demarcador. Não existe nenhuma extensão de arquivo, mas faz sentido usar “.model”. O parâmetro de formato tem o valor padrão ModelFormat.CNTKv2, por isso, ele poderia ser omitido. Uma alternativa é usar o novo formato do Open Neural Network Exchange (ONNX).

Lembre-se de que o programa de demonstração criou um objeto nnet (sem softmax na saída) e um objeto model (com softmax). Você normalmente desejará salvar a versão softmax de um modelo treinado, mas pode salvar o objeto não softmax se desejar.

Assim que o modelo for salvo, você pode carregá-lo na memória da seguinte forma:

model = C.ops.functions.Function.Load(".\\Models\\seed_dnn.model")

Então, o modelo poderá ser usado como se tivesse acabado de ser treinado. Observe que há um pouco de assimetria nas chamadas para salvar e carregar, pois salvar é um método em um objeto Function e carregar é um método estático da classe Function.

Conclusão

Muitos problemas de classificação podem ser manipulados usando uma simples rede neural de encaminhamento do feed (FNN) com uma única camada oculta. Em teoria, dado certos pressupostos, uma FNN pode tratar de qualquer problema que uma rede neural profunda também pode. No entanto, em prática, às vezes uma rede neural profunda é mais fácil de treinar que uma FNN. A base matemática para essas ideias é chamada de teorema de aproximação universal (ou, à vezes, Teorema Cybenko).

Se você é novo em classificação de rede, o número de decisões que você tem que fazer pode parecer intimidador. Você deve decidir sobre o número de camadas ocultas, o número de nós em cada camada, um esquema de inicialização e função de ativação para cada camada oculta, um algoritmo de treinamento e os parâmetros do algoritmo de treinamento como a taxa de aprendizagem e o termo do momento. No entanto, com a prática você desenvolverá rapidamente um conjunto de regras de thumb para os tipos de problemas com os quais você lida.


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