Janeiro de 2019
Volume 34 – Número 1
[Machine Learning]
Introdução ao PyTorch no Windows
Por James McCaffrey
Embora seja bastante difícil, é possível criar redes neurais a partir de um código bruto. Felizmente, há muitas bibliotecas de código-fonte aberto que podem ser usadas para acelerar o processo. Entre elas, temos o CNTK (Microsoft), o TensorFlow (Google) e o scikit-learn. A maioria das bibliotecas de redes neurais é escrita em C++ para obter desempenho, mas contam com uma API do Python para sua conveniência.
Neste artigo, mostrarei como começar a usar a conhecida biblioteca PyTorch. Em comparação a muitas outras bibliotecas de rede neural, o PyTorch opera em um nível inferior de abstração. Isso propicia um controle maior sobre o código e permite uma personalização mais fácil, em detrimento da necessidade de escrever código adicional.
A melhor maneira de saber o rumo que este artigo tomará é examinar o programa de demonstração na Figura 1. O programa de demonstração lê o conhecido conjunto de dados da íris na memória. A meta é prever a espécie de uma flor de íris (setosa, versicolor ou virginica) com base em quatro valores: comprimento da sépala, largura da sépala, comprimento da pétala e largura da pétala. Uma sépala é uma estrutura do tipo folha.
Figura 1 O exemplo de conjunto de dados da íris usando o PyTorch
O conjunto de dados completo da íris tem 150 itens. O programa de demonstração usa 120 itens para treinamento e 30 itens para teste. Primeiro, a demonstração cria uma rede neural usando o PyTorch e depois treina a rede usando 600 iterações. Após o treinamento, o modelo é avaliado usando os dados de teste. O modelo treinado tem uma precisão de 90,00%, o que significa que ele prevê corretamente a espécie de 27 entre 30 itens de teste.
A demonstração é concluída prevendo a espécie de uma nova flor de íris nunca vista antes que tem valores de sépala e pétala (6,1; 3,1; 5,1; 1,1). As probabilidades da previsão são (0,0454; 0,6798; 0,2748), o que mapeia para uma previsão de versicolor.
Este artigo pressupõe que você tenha habilidades de programação intermediárias ou mais aprimoradas com uma linguagem da família C, mas não tem familiaridade alguma com o PyTorch. O código de demonstração completo é apresentado neste artigo. O código-fonte e os dois arquivos de dados usados pela demonstração também estão disponibilizados no download que acompanha este artigo. Qualquer verificação de erro normal foi removida para manter as ideias principais o mais claras possível.
Instalação do PyTorch
A instalação do PyTorch envolve duas etapas. Primeiro, é preciso instalar o Python e vários pacotes auxiliares obrigatórios, como NumPy e SciPy. Em seguida, o PyTorch é instalado como um pacote complementar. Embora seja possível instalar o Python e os pacotes necessários para executar o PyTorch separadamente, é muito melhor instalar uma distribuição do Python. Recomendo fortemente usar a distribuição Anaconda do Python já que ela oferece todos os pacotes necessários para executar o PyTorch, além de muitos outros pacotes úteis. Neste artigo, falo sobre a instalação em um computador Windows 10. A instalação nos sistemas macOS e Linux é semelhante.
Coordenar versões compatíveis do Python, pacotes auxiliares necessários e o PyTorch é um desafio nada trivial. Quase todas as falhas de instalação que já vi aconteceram por causa de incompatibilidades de versão. No momento em que estou escrevendo este artigo, estou usando o PyTorch 0.4.1 e o Anaconda3 5.2.0 (que contém o Python 3.6.5, o NumPy 1.14.3 e o SciPy 1.1.0). São todas versões bastante estáveis, mas como o PyTorch é relativamente novo e está em desenvolvimento contínuo, no momento em que você estiver lendo este artigo é possível que já exista uma versão mais recente disponível.
Antes de começar, recomendo que você desinstale todos os sistemas Python existentes em seu computador usando o Painel de Controle do Windows | Programas e Recursos. Sugiro também a criação de um diretório C:\PyTorch para armazenar arquivos de instalação e de projeto (código e dados).
Para instalar a distribuição Anaconda, acesse repo.continuum.io/archive e procure o arquivo Anaconda3-5.2.0-Windows-x86_64.exe, que é um executável de extração automática. Se você clicar no link, uma caixa de diálogo com botões será exibida para Executar ou Salvar. Você pode clicar no botão Executar.
O instalador do Anaconda é muito fácil de usar. Você verá o conjunto com oito telas de um assistente de instalação. Basta aceitar todos os padrões e clicar no botão Avançar em cada tela, com apenas uma exceção. Ao chegar à tela que pergunta se você quer adicionar o Python à variável de ambiente do CAMINHO do sistema, o padrão está desmarcado (não). Recomendo marcar essa opção para que não seja preciso editar manualmente o CAMINHO do sistema. As configurações padrão salvarão o interpretador do Python e mais de 500 pacotes compatíveis no diretório C:\Users\<user>\AppData\Local\ Continuum\Anaconda3.
Para instalar a biblioteca PyTorch, acesse pytorch.org e localize o link “Versões anteriores do PyTorch” e clique nele. Procure um arquivo chamado torch-0.4.1-cp36-cp36m-win_amd64.whl. Este é um arquivo “wheel” do Python. Imagine um arquivo .whl como algo parecido a um arquivo .msi do Windows. Se clicar no link, será apresentada uma opção para Abrir ou Salvar. Use a opção Salvar como e coloque o arquivo .whl em seu diretório C:\PyTorch. Se não conseguir localizar o arquivo .whl do PyTorch, experimente bit.ly/2SUiAuj, que é onde o arquivo estava quando escrevi este artigo.
Você pode instalar o PyTorch usando o utilitário pip do Python, obtido com a distribuição do Anaconda. Abra um shell de comando do Windows e navegue até o diretório no qual você salvou o arquivo .whl do PyTorch. Depois, insira o seguinte comando:
C:\PyTorch> pip install torch-0.4.1-cp36-cp36m-win_amd64.whl
A instalação é rápida, mas muita coisa pode dar errado. Se a instalação falhar, leia com atenção as mensagens de erro no shell. É bem provável que o problema seja de compatibilidade com a versão.
Para verificar se o Python e o PyTorch foram instalados com êxito, abra um shell de comando e digite “python” para iniciar o interpretador do Python. Você verá o prompt “>>>” do Python. Em seguida, digite os seguintes comandos (observe que há dois caracteres de sublinhado consecutivos no comando da versão):
C:\>python
>>> import torch as T
>>> T.__version__
'0.4.1'
>>> exit()
C:\>
Se você vir as respostas mostradas aqui, parabéns! Você está pronto para começar a escrever códigos de aprendizado de máquina de rede neural usando o PyTorch.
Preparação do conjunto de dados da íris
O conjunto de dados brutos da íris pode ser encontrado em bit.ly/1N5br3h. Os dados se parecem com isso:
5.1, 3.5, 1.4, 0.2, Iris-setosa
4.9, 3.0, 1.4, 0.2, Iris-setosa
...
7.0, 3.2, 4.7, 1.4, Iris-versicolor
6.4, 3.2, 4.5, 1.5, Iris-versicolor
...
6.2, 3.4, 5.4, 2.3, Iris-virginica
5.9, 3.0, 5.1, 1.8, Iris-virginica
Os quatro primeiros valores de cada linha são: o comprimento da sépala, a largura da sépala, o comprimento da pétala e a largura da pétala de uma flor. O quinto item é a espécie que será determinada. Os dados brutos têm 50 setosas, seguidos por 50 versicolores e 50 virginicas. O arquivo de treinamento reúne os primeiros 40 de cada espécie (120 itens) e o arquivo de teste reúne os 10 últimos de cada espécie (30 itens). Como existem quatro variáveis de previsão, não é possível fazer um gráfico do conjunto de dados. No entanto, é possível ter uma ideia aproximada da estrutura dos dados examinando o gráfico na Figura 2.
Figura 2 Dados parciais da íris
As redes neurais só leem números, portanto, a espécie deve estar codificada. Na maioria das bibliotecas de redes neurais, seria possível substituir a setosa por (1, 0, 0), a versicolor por (0, 1, 0) e a virginica por (0, 0, 1). Isso é chamado de codificação 1-de-N ou one-hot. No entanto, o PyTorch executa a codificação one-hot nos bastidores e espera um 0, 1 ou 2 para as três classes. Assim, os dados codificados para o PyTorch são:
5.1, 3.5, 1.4, 0.2, 0
4.9, 3.0, 1.4, 0.2, 0
...
7.0, 3.2, 4.7, 1.4, 1
6.4, 3.2, 4.5, 1.5, 1
...
6.2, 3.4, 5.4, 2.3, 2
5.9, 3.0, 5.1, 1.8, 2
Na maioria das situações, você deveria normalizar as variáveis de previsão, normalmente fazendo o dimensionamento de modo que todos os valores fiquem entre 0,0 e 1,0, usando o que é chamado de normalização mín-máx. Não normalizei os dados da íris para deixar este artigo um pouco mais simples. Ao trabalhar com redes neurais, geralmente crio uma pasta raiz para o problema, como C:\PyTorch\Iris, e um subdiretório chamado Dados para manter os arquivos de dados.
O programa de demonstração
O programa de demonstração completo, com algumas pequenas edições para economizar espaço, é apresentado na Figura 3. Recuei apenas dois em vez dos habituais quatro espaços para economizar espaço. Também usei o Bloco de Notas para editar o programa de demonstração, mas há dezenas de editores do Python que contam com recursos avançados. Observe que o Python utiliza o caractere ‘\’ para continuação de linha.
Figura 3 O programa de demonstração do conjunto de dados da íris
# iris_nn.py
# PyTorch 0.4.1 Anaconda3 5.2.0 (Python 3.6.5)
import numpy as np
import torch as T
# -----------------------------------------------------------
class Batch:
def __init__(self, num_items, bat_size, seed=0):
self.num_items = num_items; self.bat_size = bat_size
self.rnd = np.random.RandomState(seed)
def next_batch(self):
return self.rnd.choice(self.num_items, self.bat_size,
replace=False)
# -----------------------------------------------------------
class Net(T.nn.Module):
def __init__(self):
super(Net, self).__init__()
self.fc1 = T.nn.Linear(4, 7)
T.nn.init.xavier_uniform_(self.fc1.weight) # glorot
T.nn.init.zeros_(self.fc1.bias)
self.fc2 = T.nn.Linear(7, 3)
T.nn.init.xavier_uniform_(self.fc2.weight)
T.nn.init.zeros_(self.fc2.bias)
def forward(self, x):
z = T.tanh(self.fc1(x))
z = self.fc2(z) # see CrossEntropyLoss() below
return z
# -----------------------------------------------------------
def accuracy(model, data_x, data_y):
X = T.Tensor(data_x)
Y = T.LongTensor(data_y)
oupt = model(X)
(_, arg_maxs) = T.max(oupt.data, dim=1)
num_correct = T.sum(Y==arg_maxs)
acc = (num_correct * 100.0 / len(data_y))
return acc.item()
# -----------------------------------------------------------
def main():
# 0. get started
print("\nBegin Iris Dataset with PyTorch demo \n")
T.manual_seed(1); np.random.seed(1)
# 1. load data
print("Loading Iris data into memory \n")
train_file = ".\\Data\\iris_train.txt"
test_file = ".\\Data\\iris_test.txt"
train_x = np.loadtxt(train_file, usecols=range(0,4),
delimiter=",", skiprows=0, dtype=np.float32)
train_y = np.loadtxt(train_file, usecols=[4],
delimiter=",", skiprows=0, dtype=np.float32)
test_x = np.loadtxt(test_file, usecols=range(0,4),
delimiter=",", skiprows=0, dtype=np.float32)
test_y = np.loadtxt(test_file, usecols=[4],
delimiter=",", skiprows=0, dtype=np.float32)
# 2. define model
net = Net()
# -----------------------------------------------------------
# 3. train model
net = net.train() # set training mode
lrn_rate = 0.01; b_size = 12
max_i = 600; n_items = len(train_x)
loss_func = T.nn.CrossEntropyLoss() # applies softmax()
optimizer = T.optim.SGD(net.parameters(), lr=lrn_rate)
batcher = Batch(num_items=n_items, bat_size=b_size)
print("Starting training")
for i in range(0, max_i):
if i > 0 and i % (max_i/10) == 0:
print("iteration = %4d" % i, end="")
print(" loss = %7.4f" % loss_obj.item(), end="")
acc = accuracy(net, train_x, train_y)
print(" accuracy = %0.2f%%" % acc)
curr_bat = batcher.next_batch()
X = T.Tensor(train_x[curr_bat])
Y = T.LongTensor(train_y[curr_bat])
optimizer.zero_grad()
oupt = net(X)
loss_obj = loss_func(oupt, Y)
loss_obj.backward()
optimizer.step()
print("Training complete \n")
# 4. evaluate model
net = net.eval() # set eval mode
acc = accuracy(net, test_x, test_y)
print("Accuracy on test data = %0.2f%%" % acc)
# 5. save model
# TODO
# -----------------------------------------------------------
# 6. make a prediction
unk = np.array([[6.1, 3.1, 5.1, 1.1]], dtype=np.float32)
unk = T.tensor(unk) # to Tensor
logits = net(unk) # values do not sum to 1.0
probs_t = T.softmax(logits, dim=1) # as Tensor
probs = probs_t.detach().numpy() # to numpy array
print("\nSetting inputs to:")
for x in unk[0]: print("%0.1f " % x, end="")
print("\nPredicted: (setosa, versicolor, virginica)")
for p in probs[0]: print("%0.4f " % p, end="")
print("\n\nEnd Iris demo")
if __name__ == "__main__":
main()
A estrutura de um programa PyTorch difere um pouco do programa de outras bibliotecas. Na demonstração, a classe Batch definida pelo programa oferece um número específico de itens para treinamento. A classe Net define uma rede neural 4-7-3. A precisão da função calcula a precisão de classificação (porcentagem de previsões corretas) de dados usando uma rede ou modelo específicos. Toda a lógica de controle está contida em uma função principal.
Como o PyTorch e o Python estão sendo desenvolvidos rapidamente, você deve incluir um comentário indicando quais versões estão sendo usadas. Muitos programadores novatos no uso do Python ficam surpresos ao descobrir que o Python base não tem suporte para matrizes. As matrizes NumPy são usadas pelo PyTorch, portanto, você constantemente importará o pacote do NumPy.
Definição da rede neural
A definição da rede neural começa com:
class Net(T.nn.Module):
def __init__(self):
super(Net, self).__init__()
self.fc1 = T.nn.Linear(4, 7)
T.nn.init.xavier_uniform_(self.fc1.weight)
T.nn.init.zeros_(self.fc1.bias)
...
A primeira linha de código indica que a classe herda uma classe T.nn.Module, que contém funções para criar uma rede neural. Você pode pensar na função __init__ como um construtor de classe. O objeto fc1 (“camada 1 totalmente conectada”) é a camada de rede oculta que espera quatro valores de entrada (os valores de previsão) e tem sete nós de processamento. A quantidade de nós ocultos é um hiperparâmetro e deve ser determinada por tentativa e erro. Os pesos da camada oculta são inicializados usando o algoritmo uniforme Xavier, que é chamado de uniforme Glorot na maioria das outras bibliotecas. Todos os desvios da camada oculta são inicializados em zero.
A camada de saída da rede é definida por:
self.fc2 = T.nn.Linear(7, 3)
T.nn.init.xavier_uniform_(self.fc2.weight)
T.nn.init.zeros_(self.fc2.bias)
A camada de saída espera sete entradas (da camada oculta) e produz três valores de saída, um para cada espécie possível. Observe que a camada oculta e de saída não estão logicamente conectadas no momento. A conexão é estabelecida pela função de encaminhamento necessária:
def forward(self, x):
z = T.tanh(self.fc1(x))
z = self.fc2(z) # no softmax!
return z
A função aceita x, que se trata dos valores de previsão de entrada. Esses valores são passados para a camada oculta, e os resultados são então passados para a função tanh de ativação. Esse resultado é passado para a camada de saída e os resultados finais são retornados. Ao contrário de muitas bibliotecas de rede neural, com o PyTorch você não aplica a ativação softmax à camada de saída porque ela será aplicada automaticamente pela função de perda de treinamento. Se você tivesse aplicado softmax à camada de saída, sua rede ainda funcionaria, mas o treinamento ficaria mais lento porque softmax estaria sendo aplicada duas vezes.
Carregamento dos dados na memória
Ao usar o PyTorch, você carrega os dados na memória em matrizes NumPy e depois converte as matrizes a objetos Tensor do PyTorch. Você pode pensar no Tensor como uma matriz sofisticada que pode ser manipulada por um processador GPU.
Há várias maneiras de carregar dados em uma matriz NumPy. Entre meus colegas, a técnica mais comum é usar o pacote Pandas do Python (originalmente “dados do painel”, agora chamado de “análise de dados Python”). No entanto, como o Pandas tem uma certa curva de aprendizado, o programa de demonstração usa a função loadtxt do NumPy para simplificar. Os dados de treinamento são carregados da seguinte forma:
train_file = ".\\Data\\iris_train.txt"
train_x = np.loadtxt(train_file, usecols=range(0,4),
delimiter=",", skiprows=0, dtype=np.float32)
train_y = np.loadtxt(train_file, usecols=[4],
delimiter=",", skiprows=0, dtype=np.float32)
O PyTorch espera que os valores de previsão estejam em uma matriz do tipo matriz-de-matrizes e que os valores de classe estejam em uma matriz. Depois que essas instruções forem executadas, a matriz train_x terá 120 linhas e quatro colunas e train_y será uma matriz com 120 valores. A maioria das bibliotecas de rede neurais, incluindo PyTorch, usa dados float32 como padrão porque a precisão obtida usando variáveis de 64 bits não compensa a perda de desempenho sofrida.
Treinando a rede neural
A demonstração cria a rede neural e depois prepara o treinamento com estas instruções:
net = Net()
net = net.train() # set training mode
lrn_rate = 0.01; b_size = 12
max_i = 600; n_items = len(train_x)
loss_func = T.nn.CrossEntropyLoss() # applies softmax()
optimizer = T.optim.SGD(net.parameters(), lr=lrn_rate)
batcher = Batch(num_items=n_items, bat_size=b_size)
Para a demonstração, não é preciso configurar a rede para o modo de treinamento porque este não usa a normalização de desligamento ou lote, o que gera fluxos de execução diferentes para treinamento e avaliação. A taxa de aprendizagem (0,01), o tamanho do lote (12) e o máximo de iterações de treinamento (600) são hiperparâmetros. A demonstração usa iterações em vez de épocas porque uma época geralmente se refere ao processamento de todos os itens de treinamento, um por vez. Aqui, uma iteração significa o processamento de apenas 12 dos itens de treinamento.
A função CrossEntropyLoss é usada para medir erros de problemas de classificação multiclasse em que há três ou mais classes a serem previstas. Um erro comum é experimentá-la e usá-la na classificação binária. A demonstração usa o descendente do gradiente estocástico, que é a forma mais rudimentar de otimização de treinamento. Para problemas realistas, o PyTorch oferece suporte a algoritmos sofisticados, incluindo a estimativa de momento adaptável (Adam), gradiente adaptável (Adagrad) e propagação ao quadrado média resiliente (RMSprop).
A classe Batch definida pelo programa implementa o mecanismo de envio em lote mais simples possível. Em cada chamada para sua função next_batch, são retornados 12 índices selecionados aleatoriamente a partir dos 120 índices possíveis de dados de treinamento. Essa abordagem não garante que o todos os itens de treinamento sejam usados a mesma quantidade de vezes. Em um cenário de não demonstração, provavelmente você desejará implementar um criador de lotes mais sofisticado que escolhe índices diferentes aleatoriamente até que todos tenham sido selecionados uma vez e que depois se reinicia automaticamente.
O treinamento é realizado exatamente 600 vezes. A cada 600/10 = 60 iterações, a demonstração exibe informações de progresso:
for i in range(0, max_i):
if i > 0 and i % (max_i/10) == 0:
print("iteration = %4d" % i, end="")
print(" loss = %7.4f" % loss_obj.item(), end="")
acc = accuracy(net, train_x, train_y)
print(" accuracy = %0.2f%%" % acc)
O valor médio de perda/erro de entropia cruzada para o lote atual de 12 itens de treinamento pode ser acessado pela função de item do objeto. Em geral, a perda de entropia cruzada é de difícil interpretação durante o treinamento, mas você deve monitorá-la para se certificar de que ela esteja diminuindo gradualmente, o que indica que o treinamento está funcionando.
Ainda que seja um pouco incomum, no momento em que escrevo este artigo, o PyTorch não tem uma função integrada para oferecer precisão de classificação. A função de precisão definida pelo programa calcula a precisão da classificação do modelo usando os valores atuais de pesos e desvios. A precisão é muito mais fácil de interpretar em relação à perda ou ao erro, mas se trata de uma métrica mais rudimentar.
Dentro do loop de treinamento, um lote de itens é selecionado entre o conjunto de dados de 120 itens e é convertido em objetos Tensor:
curr_bat = batcher.next_batch()
X = T.Tensor(train_x[curr_bat])
Y = T.LongTensor(train_y[curr_bat])
Lembre-se de que curr_bat é uma matriz de 12 índices nos dados de treinamento, assim, train_x [curr_bat] tem 12 linhas e 4 colunas. Essa matriz é convertida em objetos Tensor do PyTorch, passando a matriz para a função Tensor. No caso de um problema de classificação, você deve converter os valores de rótulo de classe codificados em objetos LongTensor em vez de objetos Tensor.
O treinamento real é executado conforme estas cinco instruções:
optimizer.zero_grad()
oupt = net(X)
loss_obj = loss_func(oupt, Y)
loss_obj.backward()
optimizer.step()
Essencialmente, você pode considerar essas instruções como mágicas do PyTorch que realizam o treinamento usando retropropagação. Primeiro você deve zerar o peso e os valores de gradiente de desvio em relação à iteração anterior. A chamada para a função net passa o lote atual de 12 objetos Tensor para a rede e calcula os 12 valores de saída usando a função de encaminhamento. As chamadas para backward e step calculam os valores de gradiente e os usam para atualizar pesos e desvios.
Avaliação e uso do modelo
Após a conclusão do treinamento, a demonstração calcula a precisão do modelo quanto aos dados de teste:
net = net.eval() # set eval mode
acc = accuracy(net, test_x, test_y)
print("Accuracy on test data = %0.2f%%" % acc)
Assim como antes, não é preciso definir o modelo como modo de avaliação neste exemplo, mas não custa nada deixar isso explícito. O programa de demonstração não salva o modelo treinado, mas, em um cenário de não demonstração, talvez seja interessante salvá-lo. O PyTorch, juntamente com a maioria das outras bibliotecas de rede neural (com a exceção notável do TensorFlow), tem suporte para o formato Open Neural Network Exchange (ONNX).
A demonstração usa o modelo treinado para prever a espécie de uma nova flor de íris nunca vista antes:
unk = np.array([[6.1, 3.1, 5.1, 1.1]], dtype=np.float32)
unk = T.tensor(unk) # to Tensor
logits = net(unk) # values do not sum to 1.0
probs_t = T.softmax(logits, dim=1) # as Tensor
probs = probs_t.detach().numpy() # to numpy array
A chamada para a função net retorna três valores que não necessariamente somam 1,0, por exemplo, (3,2; 4,5; 0,3), portanto, a demonstração aplica a softmax para forçar os valores de saída a somarem 1,0 e poderem ser interpretados livremente como probabilidades. Os valores são objetos Tensor, portanto, são convertidos em uma matriz do NumPy para que possam ser exibidos com mais facilidade.
Conclusão
Este artigo apresentou apenas uma pequena amostra da biblioteca PyTorch, mas deve fornecer todas as informações necessárias para começar a testá-lo. Conforme demonstrado aqui, o PyTorch é bastante diferente do CNTK, TensorFlow e scikit-learn, além de operar em um nível inferior. Uma pergunta comum é: “Qual biblioteca de rede neural é melhor?” Em um mundo perfeito, você poderia reservar algum tempo e conhecer todas as principais bibliotecas. Mas, como essas bibliotecas são bastante complicadas, na vida real, a maioria dos meus colegas tem apenas uma biblioteca principal. Na minha opinião e do ponto de vista técnico, as três melhores bibliotecas são CNTK, TensorFlow/Keras e PyTorch. No entanto, como todas elas são excelentes, escolher uma só em detrimento de outra depende principalmente de seu estilo de programação e de qual delas é a mais usada por sua empresa ou seus colegas.
Dr. James McCaffreytrabalha para a Microsoft Research em Redmond, Washington. Ele trabalhou em vários produtos da Microsoft, incluindo Internet Explorer e Bing. Dr. McCaffrey pelo email jamccaff@microsoft.com.
Agradecemos aos seguintes especialistas técnicos da Microsoft pela revisão deste artigo: Brian Broll, Yihe Dong, Chris Lee