Skip to content

Modelos de Sistemas - Clientes Maliciosos no Treinamento Cliente Servidor

Nesse laboratório vamos explorar o modelo de clientes bizantinos no treinamento de modelos considerando uma arquitetura cliente servidor. Para isso, vamos utilizar como base o Laboratório 1 e também a ferramenta ray para permitir instânciar múltiplos clientes.

O que é Ray?

O Ray é uma biblioteca de código aberto projetada para a construção de aplicativos distribuídos e paralelos de maneira fácil e escalável. Ele oferece uma API simples para executar tarefas paralelamente e é amplamente utilizado para aprendizado de máquina, processamento de dados em grande escala, treinamento de modelos e inferências.

Com o Ray, você pode facilmente transformar funções Python em tarefas distribuídas e classes Python em "actors" (atores), que são objetos distribuídos e paralelos. Ele pode ser executado em um único computador ou em um cluster de máquinas, oferecendo flexibilidade e escalabilidade.

Como funciona o Ray?

  • Tasks (Tarefas): Qualquer função Python pode ser transformada em uma tarefa Ray para ser executada em paralelo. Essas tarefas podem ser distribuídas em diferentes núcleos de CPU ou máquinas.

  • Actors (Atores): Objetos que podem manter estado ao longo de várias tarefas e também podem ser distribuídos. Um "actor" permite que você tenha múltiplas instâncias paralelas e estados independentes.

  • Objects (Objetos): No Ray, tarefas e atores operam em objetos. Esses objetos são conhecidos como objetos remotos porque podem ser armazenados em qualquer lugar dentro de um cluster Ray. Usamos referências de objeto (object refs) para nos referir a esses objetos remotos. O armazenamento de objetos de memória compartilhada distribuída do Ray armazena em cache esses objetos remotos, e cada nó no cluster tem seu próprio armazenamento de objetos. Em um cluster, um objeto remoto pode existir em um ou vários nós, independentemente de qual nó contém a(s) referência(s) de objeto.

O Ray é amplamente utilizado para resolver problemas de alto desempenho como busca de hiperparâmetros, treinamento de modelos em grande escala e inferência paralela.

Ray Cluster

Um cluster Ray consiste em um grupo de nós de trabalho que são conectados a um nó de cabeça Ray central. Esses clusters podem ser configurados com um tamanho fixo ou podem ser dimensionados automaticamente de forma dinâmica com base nos requisitos de recursos dos aplicativos em execução no cluster

  • Cluster: Um cluster Ray compreende uma coleção de nós de trabalho vinculados a um nó principal Ray central. Esses clusters podem ter um tamanho predefinido ou aumentar ou diminuir dinamicamente com base nos requisitos de recursos dos aplicativos que operam no cluster.

  • Head node: Em cada cluster Ray, há um nó principal designado responsável pelas tarefas de gerenciamento do cluster (i.e., head node), como executar os processos do autoscaler e do driver Ray. Embora o nó principal funcione como um nó de trabalho regular, ele também pode receber tarefas e atores, o que não é ideal para clusters de grande escala.

  • Worker node: Os workers em um cluster Ray são os únicos responsáveis ​​por executar o código do usuário dentro das tarefas e atores Ray. Eles não estão envolvidos na execução de nenhum processo de gerenciamento de nó principal. Esses nós de trabalho desempenham um papel crucial no agendamento distribuído e são responsáveis ​​por armazenar e distribuir objetos Ray por toda a memória do cluster.

  • Autoscaling: O autoscaler Ray, em execução no nó principal, ajusta o tamanho do cluster com base nos requisitos de recursos da carga de trabalho Ray. Quando a carga de trabalho ultrapassa a capacidade do cluster, o autoscaler tenta adicionar mais nós de trabalho. Por outro lado, ele remove nós de trabalho ociosos do cluster. É crucial observar que o autoscaler responde exclusivamente a solicitações de recursos de tarefa e ator e não considera métricas de aplicativo ou utilização de recursos físicos.

ray.init()

Paralelizando código com Ray

Considere que temos a seguinte função que faz alguma coisa e precisamos executa-la múltiplas vezes:

import time

def faz_alguma_coisa():
    #print("fazendo alguma coisa")
    time.sleep(0.2)
    return 42

Por que 42?

Nesse exemplos, vamos considerar que precisamos executa-la 50 vezes, portanto, podemos utilizar o seguinte código para fazer esse processo

start_time     = time.time()
tempos_sem_ray = []
for _ in range(50):
    faz_alguma_coisa()
    tempos_sem_ray.append(time.time() - start_time)    
total_time = time.time() - start_time
print(f"Tempo total sem Ray: {total_time:.2f} segundos")
Como resultado, temos:
Tempo total sem Ray: 10.18 segundos
Agora vamos paralelizar e distribuir esse código para executa-lo mais rápido com ray. Para isso, precisamos apenas de um decorator @ray.remote na nossa função e vetorizar as chamas da função, como visto no exemplo a seguir:

@ray.remote
def faz_alguma_coisa_ray():
    time.sleep(0.2)
    return 42

start_time_ray = time.time()
tempos_com_ray = []
futures        = []
# futures = [faz_alguma_coisa_ray.remote() for _ in range(50)]
for _ in range(50):
    futures.append(faz_alguma_coisa_ray.remote())
    tempos_com_ray.append(time.time() - start_time_ray)
results = ray.get(futures)
total_time_ray = time.time() - start_time_ray
print(f"Tempo total com Ray: {total_time_ray:.2f} segundos")
print(f"O speedup com ray foi de: {total_time / total_time_ray:.2f}x")
Com a execução do Ray temos:
Tempo total com Ray: 1.47 segundos
O speedup com ray foi de: 6.93x

Visualizando Speed-up

Para visualizar o ganho de desempenho vamos plotar o tempo para finalizar cada execução da função com e sem o ray, o código para plotagem é apresentado a seguir:

import matplotlib.pyplot as plt

fig, ax = plt.subplots(nrows=1, ncols=2, figsize=(12, 3))
ax[0].plot(tempos_sem_ray, label="Sem Ray")
ax[0].plot(tempos_com_ray, label="com Ray")
ax[0].set_yscale("log")
ax[0].set_xlabel("Número de tarefas")
ax[0].set_ylabel("Tempo (s)")
ax[0].legend()
ax[0].set_title("Tempo de execução acumulado")
ax[0].grid(True, linestyle=':', alpha=0.5)

ax[1].barh(["Sem Ray", "Com Ray"], [total_time, total_time_ray], color=['blue', 'orange'], ec='k')
ax[1].set_xlabel("Tempo total (s)")
ax[1].set_title("Comparação de tempo total")
ax[1].grid(True, linestyle=':', alpha=0.5)
ax[1].set_axisbelow(True)

Quando utilizamos Tasks?

As tasks são ideais para situações em que você precisa executar funções que são independentes entre si e podem ser processadas em paralelo. Elas são particularmente úteis quando as tarefas não compartilham estado e podem ser executadas de forma assíncrona, permitindo que o sistema utilize melhor os recursos disponíveis.

Um exemplo típico de uso de tasks é em aplicações que realizam cálculos intensivos ou processamentos de dados que podem ser divididos em partes menores. Ao transformar essas funções em tasks Ray, você pode aproveitar a capacidade de processamento paralelo, reduzindo significativamente o tempo total de execução.

Além disso, as tasks são apropriadas quando você precisa de um modelo de programação simples e não precisa manter o estado entre as chamadas. Isso as torna uma escolha excelente para tarefas como processamento de imagens, simulações, ou qualquer operação que possa ser realizada de forma isolada.

import ray

# Define uma função como task
@ray.remote
def quadrado(x):
    return x * x

# Executa várias tasks em paralelo
futuros = [quadrado.remote(i) for i in range(5)]

# Pega os resultados
resultados = ray.get(futuros)

print("Resultados das tasks:", resultados)

Quando utilizamos Actors?

Os actors são utilizados em situações onde é necessário manter estado entre chamadas de função ou quando as operações precisam ser sequenciais. Eles são ideais para cenários em que múltiplas tarefas precisam interagir com um objeto compartilhado, permitindo que você encapsule a lógica de estado e comportamento em uma única entidade.

Um exemplo típico de uso de actors é em aplicações que requerem contagem, gerenciamento de sessões ou qualquer situação onde o estado precisa ser persistido entre diferentes chamadas. Ao usar actors, você pode garantir que o estado seja mantido de forma segura e que as operações sejam executadas de maneira ordenada, evitando problemas de concorrência.

Além disso, actors são úteis quando você precisa de uma interface de programação orientada a objetos, permitindo que você crie instâncias que podem ser manipuladas de forma independente. Isso facilita a modelagem de sistemas complexos onde diferentes componentes precisam interagir de maneira controlada.

Em resumo, utilize actors quando precisar de um modelo de programação que mantenha estado e permita interações complexas entre diferentes partes do seu sistema.

# Define um actor
@ray.remote
class Contador:
    def __init__(self):
        self.valor = 0

    def incrementar(self):
        self.valor += 1
        return self.valor

    def get_valor(self):
        return self.valor

# Cria um actor (um "objeto remoto")
contador = Contador.remote()

# Faz algumas chamadas no actor
print(ray.get(contador.incrementar.remote()))  # 1
print(ray.get(contador.incrementar.remote()))  # 2
print(ray.get(contador.get_valor.remote()))    # 2

Múltiplos clientes com Ray e gRPC

Agora vamos simular múltiplos clientes em uma comunicação gRPC com um servidor, baseado no código do Laboratório 1.

Tip

É importante que para esse exemplo funcione você deve ter o arquivo .proto do exemplo anterior já compilado e gerados os códigos grpc e pb2

Essa forma, podemos tranformar a requisição de um cliente em uma função, adicionar um decorator @ray.remote e disparar múltiplas requisições para o servidor, simulando múltiplos clientes.

Clientes como Tasks

import grpc
import ping_pong_pb2 as pb2
import ping_pong_pb2_grpc as pb2_grpc
import time

@ray.remote
def client_process(mensagem, client_id):
    channel = grpc.insecure_channel("localhost:50051")
    stub = pb2_grpc.PingPongStub(channel)

    message = pb2.Ping(mensagem=mensagem)
    response = stub.GetServerResponse(message)

    return response.mensagem  # Assuming the response has a 'mensagem' attribute

if __name__ == '__main__':
    client_id = 1  # Example client ID
    mensagens = ['Ping!'] * 10  # Create a list of 10 identical messages
    ids       = list(range(10))

    results = ray.get([client_process.remote(f'{message} - {client_id}', client_id) for message, client_id in zip(mensagens, ids)])
    print(results)

Important

Precisamos vetorizar as chamadas da função de requisição para que o ray execute os clientes de forma paralela.

Clientes como Actors

Agora vamos transformar o exemplo anterior em clientes como atores e fazer com que ele alterem o comportamento de forma aleatória

import grpc
import ping_pong_pb2 as pb2
import ping_pong_pb2_grpc as pb2_grpc
import ray
import random

ray.init()

@ray.remote
class PingPongActor:
    def __init__(self, message, client_id):
        self.message = message
        self.client_id = client_id
        self.channel = grpc.insecure_channel("localhost:50051")
        self.stub = pb2_grpc.PingPongStub(self.channel)

    def send_message(self):
        rnd = random.randint(0, 100)

        #altera ID com probabilidade de 50%
        if rnd < 50:
            self.client_id += 100

        message = pb2.Ping(mensagem=f'{self.message} - {self.client_id}')
        response = self.stub.GetServerResponse(message)
        return response.mensagem  

if __name__ == '__main__':
    clients_list = []

    for i in range(10):
        clients_list.append(PingPongActor.remote(f'Ping!', i))

    # Send messages using the actor    
    results = ray.get([client.send_message.remote() for client in clients_list])
    print(results)
Com isso, podemos fazer com que diferentes clientes assumam diferentes comportamentos na nossa simulação. Assim, podemos iniciar a atividade

Exercício - Os de Verdade Eu Sei Quem São?

O objetivo dessa atividade é realizar o treinamento de um modelo similar ao Laboratório 1, onde os dados são produzidos nos clientes e o modelo é treinado no servidor. Entretanto, agora temos Clientes Bizantinos no cenário. Assim, o servidor deve verificar clientes maliciosos para não considerar seus dados no treinamento. Como apresentado no exemplo a seguir:

Assim, nessa atividade você deve:

  • Implementar um sistema onde os clientes enviam os dados para que o servidor via gRPC, para realizar treinar um modelo de machine learning. Você deve utilizar a mesma estrutura de mensagens utilizada no exercício anterior
  • Utilize 5 clientes para a simulação
  • Faça a segmentação dos dados, para que os clientes não tenham os mesmos dados

Para a implementação dos clientes, considerem as seguintes situações:

  1. Clientes que não alteram os dados - (clientes normais)
  2. Clientes que alteram os todos dados de forma randômica (adiciona ruido nas features)
  3. Clientes que alteram apenas uma feature de forma randômica
  4. Cliente que flipa os rótulos dos dados

Para o servidor, você deve:

  • Implementar um método de verificação para identificar o cliente é Bizantino ou não
  • Considerar apenas os dados de clientes não Bizantinos para treinar o modelo
  • O Servidor que treina o modelo com todos os dados recebidos
  • Apresente a performance do modelo com e sem o método de detecção implementando

Para simular os clientes bizantinos, você deve considerar os seguintes cenários:

  • Cenário com apenas 1 cliente malicioso
  • Cenário com 2 clientes maliciosos
  • Cenário com 3 clientes maliciosos

Como sua solução se comporta em cada um deles?

Importante

  • O treinamento só deve acontecer depois de receber todos os dados dos clientes
  • Você pode utilizar a acurácia do modelo como métrica de performance
  • A entrega do exercício deve ser feita no Classroom.