How to Solve Optimization Problems with Azure Quantum QIO (Part 4)

Learn how to submit your cost function (QUBO or Ising) to an optimization solver.

This is part 4 of a 5-part-series explaining how to solve optimization problems with the quantum-inspired optimization path in Azure Quantum. In part 1 you learned about the overall process for preparing, submitting, and post-processing optimization jobs. In part 2 we introduced Binary Quadratic Models (BQM), which are the tool to express cost functions representing the objective (the metric to be minimized) and constraints. In part 3 you learned how to write the cost functions for the business problem introduced in part 2. In this article learn how to convert the cost function into code that can be submitted to an Azure Quantum optimization solver.

You can find the complete sourcecode of the overall solution on GitHub: https://github.com/hsirtl/aq-qio-samples/tree/main/soccer-teams.

Prepare the Environment in Azure

We’ll implement the cost function as Azure Quantum jobs in Python and will submit the job to an Azure Quantum workspace. Perform following steps to setup your environment:

  1. Get an Azure Account
  2. Install the Azure Command Line Interface (CLI)
  3. Create an Azure Quantum workspace
  4. Install the azure-quantum Python package

After the quantum workspace is generated take note of following information as we need it for authenticating at the workspace:

  • Subscription ID (format similar to XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX)
  • Resource group name
  • Name of the workspace
  • Location of the workspace (e.g., eastus)

In your project directory create a file called appsettings.json containing following JSON-code and replace the placeholders with your values.

{
  "workspace" : {
    "subscription_id": "<YOUR_SUBSCRIPTION_ID>",
    "resource_group": "<YOUR_RESOURCE_GROUP_NAME>",
    "name": "<YOUR_WORKSPACE_NAME>",
    "location": "<YOUR_WORKSPACE_REGION>"
  }
}

Template for your appsettings.json-config file containing authentication information

Implement the Cost Function as a Azure QIO Job

So far, we have created a binary quadratic model (BQM) for our business problem, the assignment of soccer players to teams. Objective was to get teams of equal strength. Constraints were that a given player can only be assigned to one team and each team can only have one goalkeeper.

For getting a solution for this optimization challenge, we need a Python program that performs following steps:

  1. Read the problem specification from a file
  2. Create the cost function as a list of terms
  3. Submit the cost function as an optimization job to an Azure Quantum optimization solver
  4. Get the job result
  5. Extract the solution from the result object

The sample-GitHub-repo contains a template file template.py that contains already empty methods that we’ll fill with necessary code in following sections.

Read the problem specification from a file

First, let’s create a file that contains our problem specification as a JSON-object. The file should look like the following:

{
    "problem": {
        "type": "soccer-teams",
        "data": {
            "teams": ["Alpha", "Beta"],
            "players" : [
                { "id": 1, "name": "Alice",  "strength": 5, "isGoalkeeper": "False" },
                { "id": 2, "name": "Bob",    "strength": 6, "isGoalkeeper": "True"  },
                { "id": 3, "name": "Claire", "strength": 8, "isGoalkeeper": "True"  },
                { "id": 4, "name": "Danny",  "strength": 4, "isGoalkeeper": "False" },
                { "id": 5, "name": "Eve",    "strength": 6, "isGoalkeeper": "False" },
                { "id": 6, "name": "Frank",  "strength": 9, "isGoalkeeper": "False" }
            ]
        }
    } 
}

The problem specification as a JSON-object

Name that file problem.json. It contains a list of teams and a list of player objects. Players are described with ids, names, strengths and a boolean value indicating, if the respective player is a goalkeeper. The template.py script already contains a function that imports this problem spec and stores it in a variable called problemData.

def getProblemData():
    with open('problem.json') as f:
        problemJson = json.load(f)
    problemData = problemJson.get("problem").get("data")
    return problemData

Function for reading the problem specification

Create Cost Function Terms for the Problem

Let’s repeat what we learned about the general form of a BQM:

$$ min \left( \sum_{i}\sum_{j>i} a_{ij} x_i x_j + \sum_{i} b_i x_i + c \right) $$

So, at the end, a BQM is a big sum of products. Some of these products have a coefficient and two binary variables (\(a_{ij} x_i x_j\)), some have one coefficient and one binary variable (\(b_i x_i\)) and some have only the coefficient (which can be aggregated to one constant \(c\)). When implementing these in Python, each product is represented by a Term-object. This object has two arguments: a coefficient (a Float) and a list of indices (each an Integer).

Example: The term \(5 x_0 x_1)\) will be implemented as Term(c = 5, indices [0,1]).

Our cost function will then be implemented as a list of Term-objects, i.e. costFunction: List[Term].

Create Terms for the Objective

def getObjectiveTerms(problemData) -> List[Term]:
    terms:  List[Term] = []
    players = problemData.get("players")
    numberOfTeams = len(problemData.get("teams"))
    numberOfPlayers = len(problemData.get("players"))
    strengths = [player.get("strength") for player in players]

    for team in range(numberOfTeams):
        teamTerms = getHamiltonianForSumOfWeights([i for i in range(team * numberOfPlayers, team * numberOfPlayers + numberOfPlayers)], strengths, sum(strengths) / numberOfTeams)
        terms.extend(teamTerms)

    return terms

Function for creating Terms for the objective

def getHamiltonianForSumOfWeights(listOfIndices, listOfWeights, sumOfWeights) -> List[Term]:
    numOfIndices = len(listOfIndices)
    terms: List[Term] = []

    for i in range(numOfIndices):
        terms.append(Term(c = 2 * listOfWeights[i] * listOfWeights[i], indices = [listOfIndices[i], listOfIndices[i]]))

    for i in range(numOfIndices-1):
        for j in range(i+1, numOfIndices):
            terms.append(Term(c = 2 * listOfWeights[i] * listOfWeights[j], indices = [listOfIndices[i], listOfIndices[j]]))

    for i in range(numOfIndices):
        terms.append(Term(c = - 2 * sumOfWeights * listOfWeights[i] - 2 * listOfWeights[i] * listOfWeights[i], indices = [listOfIndices[i]]))

    return terms

Function for creating Terms minimizing variance of a sum from a certain value

Create Terms for the Constraints

Let’s first introduce a helper function that returns Terms for ensuring that at max only one of two indices can be \(1\).

def getMaxOneOutOfTwoTerms(i, j, penalty):
    terms = []
    terms.append ( Term ( c = penalty, indices = [ i , j ] ) )

    return terms

Function for creating Terms ensuring that from two indices only one can be 1

This function is useful for our next function. getOnePlayerInOneTeamConstraintTerms creates Term-objects that ensure that all players are only assigned to one team each.

def getOnePlayerInOneTeamConstraintTerms(problemData):
    terms = []
    players = problemData.get("players")
    numberOfTeams = len(problemData.get("teams"))
    numberOfPlayers = len(players)
    penalty = 100

    for i in range(numberOfPlayers):
        for j in range(numberOfTeams - 1):
            for k in range(j + 1, numberOfTeams):
                idx1 = j * numberOfPlayers + i
                idx2 = k * numberOfPlayers + i
                terms.extend(getMaxOneOutOfTwoTerms(idx1, idx2, penalty))

    return terms

Function for creating Terms ensuring that a player is only assigned to one team

Now to the second constraint: following function creates Term-objects indicating that a team can have only one goalkeeper. Let’s keep the function as general as possible, so let’s not just focus on the special case of two goalkeepers but let the function find out, who the goalkeepers are and keep them separated from each other.

def getOneGoalkeeperPerTeamTerms(problemData):
    terms = []
    players = problemData.get("players")
    numberOfTeams = len(problemData.get("teams"))
    numberOfPlayers = len(players)
    goalkeeperIndices = []
    penalty = 100

    for i in range(numberOfPlayers):
        if players[i].get("isGoalkeeper") == "True":
            goalkeeperIndices.append(i)

    for team in range(numberOfTeams):
        for i in range(len(goalkeeperIndices) - 1):
            for j in range(i + 1, len(goalkeeperIndices)):
                idx1 = team * numberOfPlayers + goalkeeperIndices[i]
                idx2 = team * numberOfPlayers + goalkeeperIndices[j]
                terms.extend(getMaxOneOutOfTwoTerms(idx1, idx2, penalty))

    return terms

Function for creating Terms ensuring that a team has only one goalkeeper

Now, we can finally implement the cost function by subsequently calling the functions above to create a longer list of Term-objects.

def createCostFunction(problemData):
    terms = []

    objectiveTerms = getObjectiveTerms(problemData)
    terms.extend(objectiveTerms)

    constraintTerms = getOnePlayerInOneTeamConstraintTerms(problemData)
    terms.extend(constraintTerms)

    constraintTerms = getOneGoalkeeperPerTeamTerms(problemData)
    terms.extend(constraintTerms)

    return terms

Function for assembling the cost function Terms

With that, we have completed the most difficult task on our side: creating a list of Terms representing our cost function. Now, we just need to send this to the Azure Quantum service for assigning values to the binary variables so that the overall function has its minimum value.

Submit the Job

For submitting our problem to Azure Quantum, we need two instantiate objects:

  • A Problem-object, containing the name of the optimization job, the problem type (QUBO or Ising) and the cost function (the list of Term-objects we created above).
  • A Solver-object, specifying the optimization solver to use with some initialization parameters.

Following code creates these two objects and submits the problem to the chosen optimization solver. Here, a Simulated Annealing solver running in an FPGA-environment is used. Other solvers are available.

problem = Problem(name="problem", problem_type=ProblemType.pubo, terms=terms)
solver = SimulatedAnnealing(workspace, platform=HardwarePlatform.FPGA, timeout=5)
job = solver.submit(problem)
job.refresh()

Job submission code

The last line makes the Python-script wait for the job completion.

Retreive the Job Result

The next step is easy: after the job completes, we can retrieve the optimization result as follows.

result = job.get_results()

Retreiving the optimization result

Unfortunately, the result is not in a format that directly maps to our business problem. In fact, the result object only contains a couple of technical metrics and a configuration-object. That object is the interesting part: it is a series of \(0\) and \(1\) values telling us, which binary variable should be assigned the value \(0\) (or \(1\) respectively) for getting the minimum value of our cost function.

{
  "version": "1.0",
  "configuration": { 
    "0": 1,"1": 0,"2": 1,"3": 0,"4": 1,"5": 0,"6": 0,"7": 1,"8": 0,"9": 1,"10": 0,"11": 1
    },
  "cost": -980.0,
  "parameters": {
    "beta_start": 0.000776,
    "beta_stop": 0.498733,
    "restarts": 216,
    "sweeps": 100
  },
  "solutions": [
    {
      "configuration": {
        "0": 1,"1": 0,"2": 1,"3": 0,"4": 1,"5": 0,"6": 0,"7": 1,"8": 0,"9": 1,"10": 0,"11": 1
      },
      "cost": -980.0
    }
  ]
}

Optimization result

So, the solver will not directly tell you, what player to assign to which team. Reason for this is, that the solver has no understanding of the business problem, or concepts like ’teams’, ‘players’, ‘strengths’, ‘goalkeepers’. We encoded these concepts in a list of Term-objects, and this is the only thing, the optimizer cares about. And that’s the only thing we actually send to Azure Quantum.

For our cost function this means that we now have the variable assignments derived from the configuration-object in the Result:

$$ x_0=1, x_1=0, x_2=1, x_3=0, x_4=1, x_5=0, x_6=0,\ … $$

These variable assignments lead to a minimum value of \(-980.0\) for our cost function.

In part 5 of this blog series, we’ll convert this result into a format that maps to our business problem. I.e., we’ll finally get the answer to our question of what player to assign to which team.

For diving deeper into this topic, I’d recommend following resources:

Important pages on the official Azure Quantum documentation