# Project 1 - Mc907/Mo651 - Mobile Robotics

### Student:
Luiz Eduardo Cartolano - RA: 183012

### Instructor:
Esther Luna Colombini

### Github Link:
[Project Repository](https://github.com/luizcartolano2/mc907-mobile-robotics)

### Subject of this Work:
The general objective of this work is to build, on the V-REP robotic simulator, an odometry and feature extraction system for the Pioneer P3-DX robot.

### Goals:
1. Implement the kinematic model of the differential robot P3DX and compute the robot odometry through its kinematic model.
2. Acquire sensor data as the robot moves around and display features (point cloud, objects, etc.) extracted from them in global coordinates.

# Code Starts Here

Import of used libraries

```python
from lib import vrep
import sys, time
from src import robot as rb
from src.utils import vrep2array
import math
from time import time
import numpy as np
import cv2
import matplotlib.pyplot as plt
from sklearn.cluster import KMeans
import skfuzzy as fuzz
import skfuzzy.control as ctrl
```

# Defining the kinematic model of the Pionner P3DX
For this project, we are going to use the configuration of the mobile robot being characterized by the position (x,y) and the orientation in a Cartesian coordinate.

Using the follow parameters:
1. $V_R$: linear velocity of the right wheel.
2. $V_L$: linear velocity of the left wheel.
3. $W$: angular velocity of the mobile robot.
4. $X$: abscissa of the robot.
5. $Y$: intercept of the robot.
6. $X,Y$ : the actual position coordinates.
7. $\theta$: orientation of the robot.
8. $L$: the distance between the driving wheels.

The kinematic model is given by these equations [1](https://www.hindawi.com/journals/cin/2016/9548482/abs/):

\begin{align}
\frac{dX}{dt} & = \frac{V_L + V_R}{2} \cdot cos(\theta) \\
\frac{dY}{dt} & = \frac{V_L + V_R}{2} \cdot sen(\theta) \\
\frac{d \theta}{dt} & = \frac{V_L - V_R}{2} \\
\end{align}

Where ($X$,$Y$ and $\theta$) are the robot actual position and orientation angle in world reference frame. In simulation, we use the discrete form to build a model of the robot. The discrete form of the kinematic model is given by the following equations:

\begin{align}
X_{k+1} & = X_k + T \cdot \frac{V_{lk} + V_{rk}}{2} \cdot cos(\theta_k + \frac{d \theta}{dt} ) \\
Y_{k+1} & = Y_k + T \cdot \frac{V_{lk} + V_{rk}}{2} \cdot sen(\theta_k + \frac{d \theta}{dt}) \\
\theta_{k+1} & = \theta_k + T \cdot \frac{V_{lk} + V_{rk}}{L} \\
\end{align}

where $X_{k+1}$ and $Y_{k+1}$ represent the position of the center axis of the mobile robot and $T$ is the sampling time.

```python
class Pose:
    """
    A class used to store the robot pose.

    ...

    Attributes
    ----------
    x : double
        The x position of the robot on the map
    y : double
        The y position of the robot on the map
    orientation : double
        The angle theta of the robot on the map

    Methods
    -------
    The class doesn't have any methods
    """
    def __init__(self, x=None, y=None, orientation=None):
        self.x = x
        self.y = y
        self.orientation = orientation
```

```python
class Odometry():
    """
    A class used to implement methods that allow a robot to calculate his own odometry.

    ...

    Attributes
    ----------
    robot : obj
        The robot object
    lastPose : obj Pose
        Store the robot's pose during his movement
    lastTimestamp : time
        Store the last timestamp
    left_vel : double
        Store the velocity of the left robot wheel
    right_vel : double
        Store the velocity of the right robot wheel
    delta_time : double
        Store how much time has passed
    delta_theta : double
        Store how the orientation change
    delta_space : double
        Store how the (x,y) change

    Methods
    -------
    ground_truth_updater()
        Function to update the ground truth, the real pose of the robot at the simulator
    odometry_pose_updater()
        Function to estimate the pose of the robot based on the kinematic model
    """
    def __init__(self, robot):
        self.robot = robot
        self.lastPose = None
        self.lastTimestamp = time()
        self.left_vel = 0
        self.right_vel = 0
        self.delta_time = 0
        self.delta_theta = 0
        self.delta_space = 0

    def ground_truth_updater(self):
        """
            Function to update the ground truth, the real pose of the robot at the simulator
        """
        # get the (x,y,z) position of the robot at the simulator
        pose = self.robot.get_current_position()
        # get the orientation of the robot (euler angles)
        orientation = self.robot.get_current_orientation()

        # return an pose object (x,y,theta)
        return Pose(x=pose[0], y=pose[1], orientation=orientation[2])

    def odometry_pose_updater(self):
        """
            Function to estimate the pose of the robot based on the knematic model
        """
        if self.lastPose is None:
            self.lastPose = self.ground_truth_updater()
            return self.lastPose

        # get the actual timestamp
        time_now = time()
        # get the robot linear velocity for the left and right wheel
        left_vel, right_vel = self.robot.get_linear_velocity()
        # calculate the difference between the acutal and last timestamp
        delta_time = time_now - self.lastTimestamp
        # calculate the angle deslocation - based on the kinematic model
        delta_theta = (right_vel - left_vel) * (delta_time / self.robot.ROBOT_WIDTH)
        # calculate the distance deslocation - based on the kinematic model
        delta_space = (right_vel + left_vel) * (delta_time / 2)

        # auxiliary function to sum angles
        add_deltha = lambda start, delta: (((start+delta)%(2*math.pi))-(2*math.pi)) if (((start+delta)%(2*math.pi))>math.pi) else ((start+delta)%(2*math.pi))

        # calculate the new X pose
        x = self.lastPose.x + (delta_space * math.cos(add_deltha(self.lastPose.orientation, delta_theta/2)))
        # calculate the new Y pose
        y = self.lastPose.y + (delta_space * math.sin(add_deltha(self.lastPose.orientation, delta_theta/2)))
        # calculate the new Orientation pose
        theta = add_deltha(self.lastPose.orientation, delta_theta)

        # uptade the state of the class
        self.lastPose = Pose(x, y, theta)
        self.lastTimestamp = time_now
        self.left_vel = left_vel
        self.right_vel = right_vel
        self.delta_time = delta_time
        self.delta_theta = delta_theta
        self.delta_space = delta_space

        return self.lastPose
```

# Defining the class that controls the robot walker

For this project we are going to use simple controllers in order to make the robot move in the map.

### Controllers:
**1. Wall-Follower**

   The Wall-Follower is an indoor navigation robot which will automatically approach and follow a wall. In order to implement the behavior, we used the two ultra-sonic in the front of the robot for obstacle avoidance. The other two ultra-sonic on the side are used to detect a wall and tell the robot’s relative position to the wall. So, with that information it's possible, to follow the pipeline:

    1. Firts the robot find a wall:
        Walks until the front sensors are busy
    2. Aling with the wall
        Robot turn in aiming to align his left side with the wall
    3. Follow the wall
        Once aligned with the wall is just keep a safe distance and follow it.

```python
class Walker():
    """
    A class used to implement methods that allow a robot to walk, based on different behaviors.

    ...

    Attributes
    ----------
    robot : obj
        the robot object

    Methods
    -------
    find_wall()
        Function that aims to find a wall in the front of the robot.
    turn_left()
        Function that aims to make the robot turn left.
    follow_wall()
        Function that aims to make the robot follow the wall that stays at his left side.
    """
    def __init__(self, robot):
        """
            Instanciate the object
        """
        self.robot = robot

    def find_wall(self):
        """
            Function that aims to find a wall in the front of the robot.
        """
        #  read the sonars
        sensors = self.robot.read_ultrassonic_sensors()

        # loop to make the robot walk until he finds a wall
        if sensors[3] > 0.45 and sensors[4] > 0.45:
            # set the initial velocity
            self.robot.set_left_velocity(3)
            self.robot.set_right_velocity(3)

            return False
        else:
            return True

    def turn_left(self):
        """
            Function that aims to make the robot turn left.
        """
        # read the sonar sensors
        sensors = self.robot.read_ultrassonic_sensors()

        if (sensors[0] > 0.5 and sensors[15] > 0.5) or (sensors[0] - sensors[15] > 0.1) or (sensors[15] - sensors[0] > 0.1):
            # start the motors velocity
            self.robot.set_left_velocity(1.5)
            self.robot.set_right_velocity(0)

            return False
        else:
            return True

    def follow_wall(self):
        """
            Function that aims to make the robot follow the wall that stays at his left side.
        """
        sensors = self.robot.read_ultrassonic_sensors()

        if sensors[3] < 0.6 or sensors[4] < 0.6 or sensors[5] < 0.6 or sensors[6] < 0.6:
            self.robot.set_right_velocity(0)
            self.robot.set_left_velocity(1.5)
        elif sensors[0] < 0.37 or sensors[1] < 0.37 or sensors[2] < 0.37:
            self.robot.set_right_velocity(0)
            self.robot.set_left_velocity(1.5)
        elif sensors[0] > 0.55 or sensors[1] > 0.55:
            self.robot.set_right_velocity(1.5)
            self.robot.set_left_velocity(0)
        else:
            self.robot.set_right_velocity(1.5)
            self.robot.set_left_velocity(1.5)

        return
```

**2. Fuzzy**

   A fuzzy control system is a control system based on fuzzy logic—a mathematical system that analyzes analog input values in terms of logical variables that take on continuous values between 0 and 1, in contrast to classical or digital logic, which operates on discrete values of either 1 or 0 (true or false, respectively).

   The input variables in a fuzzy control system are in general mapped by sets of membership functions similar to this, known as "fuzzy sets". The process of converting a crisp input value to a fuzzy value is called "fuzzification". A control system may also have various types of switch, or "ON-OFF", inputs along with its analog inputs, and such switch inputs of course will always have a truth value equal to either 1 or 0, but the scheme can deal with them as simplified fuzzy functions that happen to be either one value or another. Given "mappings" of input variables into membership functions and truth values, the microcontroller then makes decisions for what action to take, based on a set of "rules", each of the form:
~~~
    IF brake temperature IS warm AND speed IS not very fast
    THEN brake pressure IS slightly decreased.
~~~

   For this project hte implemented fuzzy was very simples, and aims just to make the robot abble to avoid obstacles on his way. He uses the ultrassonic sensors for his three inputs (front, left and right distance) and outputs the linear velocity for both weels.

```python
class FuzzyControler():
    """
    A class used to implement methods that allow a robot to walk, based on a fuzzy logic controller.

    ...

    Attributes
    ----------
    forward: skfuzzy object
        Skfuzzy input object
    left: skfuzzy object
        Skfuzzy input object
    right: skfuzzy object
        Skfuzzy input object
    output_left: skfuzzy object
        Skfuzzy output object
    output_right: skfuzzy object
        Skfuzzy output object
    rules: skfuzzy object
        List of rules to the fuzzy
    control: skfuzzy object
        Skfuzzy controller object
    simulator: skfuzzy object
        Skfuzzy simulator object

    Methods:

    -------
    create_inputs()
        Function to create skfuzzy input functions
    create_outputs()
        Function to create skfuzzy output functions
    create_rules()
        Function to create skfuzzy rules
    create_control()
        Function to create skfuzzy controller
    show_fuzzy()
        Function to show the fuzzy rules as a graph
    create_simulator()
        Function that controls the fuzzy pipeline
    simulate()
        Function that give outputs velocity based on input distance
    """

    def __init__(self):
        self.forward = None
        self.left = None
        self.right = None
        self.output_left = None
        self.output_right = None
        self.rules = []
        self.control = None
        self.simulator = None

    def create_inputs(self):
        # variable universe
        x_dst = np.arange(0, 1, 0.00001)

        # create skfuzzy object
        self.forward = ctrl.Antecedent(x_dst, 'forward')
        self.left = ctrl.Antecedent(x_dst, 'left')
        self.right = ctrl.Antecedent(x_dst, 'right')

        # set the variable universe as near, medium and far
        self.forward['near'] = fuzz.trapmf(x_dst, [0.00397, 0.0145, 0.242063492063492, 0.374])
        self.forward['medium'] = fuzz.trapmf(x_dst, [0.295, 0.366, 0.55, 0.652116402116402])
        self.forward['far'] = fuzz.trapmf(x_dst, [0.583333333333333, 0.702, 1.00, 1.0])

        self.left['near'] = fuzz.trapmf(x_dst, [0.00132, 0.0119, 0.261, 0.358465608465608])
        self.left['medium'] = fuzz.trapmf(x_dst, [0.287, 0.374, 0.55, 0.675925925925926])
        self.left['far'] = fuzz.trapmf(x_dst, [0.61, 0.705026455026455, 1.03, 1.3])

        self.right['near'] = fuzz.trapmf(x_dst, [0.00132, 0.0119, 0.261, 0.358465608465608])
        self.right['medium'] = fuzz.trapmf(x_dst, [0.287, 0.374, 0.55, 0.675925925925926])
        self.right['far'] = fuzz.trapmf(x_dst, [0.61, 0.705026455026455, 1.03, 1.3])

        return

    def create_outputs(self):
        # variable universe
        x_out = np.arange(-1,1, 0.00001)

        # create skfuzzy object
        self.output_left = ctrl.Consequent(x_out, 'output_left')
        self.output_right = ctrl.Consequent(x_out, 'output_right')

        # set the variable universe as reverse, slow, normal and fast
        self.output_left['reverse'] = fuzz.trapmf(out_dst, [-1, -1, 0, 0])
        self.output_left['slow'] = fuzz.trapmf(out_dst, [0.0, 0.0, 0.5, 0.5])
        self.output_left['normal'] = fuzz.trapmf(out_dst, [0.4, 0.4, 0.8, 0.8])
        self.output_left['fast'] = fuzz.trapmf(out_dst, [0.7, 0.7, 1.0, 1.0])

        self.output_right['reverse'] = fuzz.trapmf(out_dst, [-1, -1, 0, 0])
        self.output_right['slow'] = fuzz.trapmf(out_dst, [0.0, 0.0, 0.5, 0.5])
        self.output_right['normal'] = fuzz.trapmf(out_dst, [0.4, 0.4, 0.8, 0.8])
        self.output_right['fast'] = fuzz.trapmf(out_dst, [0.7, 0.7, 1.0, 1.0])

        return

    def create_rules(self, forward, left, right, output_left, output_right):
        # rule 1
        rule1 = ctrl.Rule(antecedent=(forward['near'] & left['near'] & right['far']),
                          consequent=(output_left['fast'], output_right['slow']))
        # rule 2
        rule2 = ctrl.Rule(antecedent=(forward['near'] & left['near'] & right['medium']),
                          consequent=(output_left['normal'], output_right['fast']))
        # rule 3
        rule3 = ctrl.Rule(antecedent=(forward['medium'] & left['medium'] & right['far']),
                          consequent=(output_left['normal'], output_right['slow']))
        # rule 4
        rule4 = ctrl.Rule(antecedent=(forward['medium'] & left['near'] & right['far']),
                          consequent=(output_left['fast'], output_right['slow']))
        # rule 5
        rule5 = ctrl.Rule(antecedent=(forward['medium'] & left['medium'] & right['medium']),
                          consequent=(output_left['slow'], output_right['slow']))
        # rule 6
        rule6 = ctrl.Rule(antecedent=(forward['medium'] & left['near'] & right['medium']),
                          consequent=(output_left['fast'], output_right['slow']))
        # rule 7
        rule7 = ctrl.Rule(antecedent=(forward['far'] & left['far'] & right['far']),
                          consequent=(output_left['fast'], output_right['fast']))
        # rule 8
        rule8 = ctrl.Rule(antecedent=(forward['near'] & left['medium'] & right['far']),
                          consequent=(output_left['slow'], output_right['fast']))
        # rule 9
        rule9 = ctrl.Rule(antecedent=(forward['near'] & left['near']),
                          consequent=(output_left['slow'], output_right['fast']))
        # rule 10
        rule10 = ctrl.Rule(antecedent=(left['near']),
                          consequent=(output_left['fast'], output_right['slow']))
        # rule 11
        rule11 = ctrl.Rule(antecedent=(forward['medium'] & left['medium'] & right['near']),
                          consequent=(output_left['normal'], output_right['reverse']))
        # rule 12
        rule12 = ctrl.Rule(antecedent=(forward['medium'] & left['far'] & right['near']),
                          consequent=(output_left['slow'], output_right['fast']))
        # rule 13
        rule13 = ctrl.Rule(antecedent=(forward['near'] & left['medium'] & right['near']),
                          consequent=(output_left['fast'], output_right['slow']))
        # rule 14
        rule14 = ctrl.Rule(antecedent=(forward['near'] & right['near']),
                          consequent=(output_left['slow'], output_right['normal']))
        # rule 15
        rule15 = ctrl.Rule(antecedent=(right['near']),
                          consequent=(output_left['reverse'], output_right['normal']))
        # rule 16
        rule16 = ctrl.Rule(antecedent=(forward['near']),
                          consequent=(output_left['reverse'], output_right['normal']))

        for i in range(1, 17):
            self.rules.append(eval("rule" + str(i)))

        return

    def create_control(self):
        # call function to create robot input
        self.create_inputs()
        # call function to create robot output
        self.create_outputs()
        # call function to create rules
        self.create_rules(self.forward, self.left, self.right, self.output_left, self.output_right)
        # create controller
        self.control = skfuzzy.control.ControlSystem(self.rules)

        return

    def show_fuzzy(self):
        if self.control is None:
            raise Exception("Control not created yet!")
        else:
            self.control.view()

        return

    def create_simulator(self):
        if self.control is None:
            # crete controller if it doensn't exist
            self.create_control()
        # create simulator object
        self.simulator = ctrl.ControlSystemSimulation(self.control)

        return

    def simulate(self, input_foward=None, input_left=None, input_right=None):
        if self.simulator is None:
            # crete simulator if it doensn't exist
            self.create_simulator()

        # if there is no input raise exception
        if input_foward is None or input_left is None or input_right is None:
            raise Exception("Inputs can't be none")

        # simulte the robot linear velocity based on given inputs
        self.simulator.input['forward'] = input_foward
        self.simulator.input['left'] = input_left
        self.simulator.input['right'] = input_right

        self.simulator.compute()

        return self.simulator.output['output_left'], self.simulator.output['output_right']
```

# Defining the Feature Extractor of the robot

For this project we tried to implement two different strategies to extract features from the environment.

### Definition:
Feature extraction and selection are important steps in the construction of a map to support the navigation of mobile robots in outdoor environments. The large amount of data acquired by the on-board sensors has to be reduced to retain only the crucial information for the navigation purpose. This procedure should be robust given the rough, dynamic and unpredictable conditions provided by outdoor scenarios.

### Extractors:
**1. Image Extractor**

Feature extraction operates on an image and returns one or more image features. Features are typically scalars (for example area or aspect ratio) or short vectors (for example the coordinate of an object or the parameters of a line). Image feature extraction is a necessary first step in using image data to control a robot. It is an information concentration step that reduces the data.

**Note:** Unfortunately, the feature extractor using image input is not working as expected, so it's not going to be used on this project. While the development we found several problemns on the quality of the hough tranformed output and also on convert the coordinates from the camera to "real world" coordinates. However, we are still going to explain the image processing techniques used and the pipeline of the extractor.

**Techniques:**

1. Hough transform

Is a feature extraction method for detecting simple shapes such as circles, lines etc in an image. A “simple” shape is one that can be represented by only a few parameters. For example, a line. The polar form of a line is represented as: $\rho = x cos(\theta) + y sen(\theta)$. Here $\rho$ represents the perpendicular distance of the line from the origin in pixels, and $\theta$ is the angle measured in radians. The accumulator because we will use the bins of this array to collect evidence about which lines exist in the image.

Steps operated in order to extract lines from image:

    1. Initialize Accumulator
    2. Detect Edges
    3. Voting by Edge Pixels

2. Corner Detection

In order to detect corners, we iterate over the founded lines, and check if exists an intercept point between then. To this, we use the following formula:
\begin{align}
P_x = \frac{(x_1 y_2-y_1 x_2)(x_3-x_4)-(x_1-x_2)(x_3 y_4-y_3 x_4)}{(x_1-x_2)(y_3-y_4)-(y_1-y_2)(x_3-x_4)} \\
P_y = \frac{(x_1 y_2-y_1 x_2)(y_3-y_4)-(y_1-y_2)(x_3 y_4-y_3 x_4)}{(x_1-x_2)(y_3-y_4)-(y_1-y_2)(x_3-x_4)}
\end{align}

```python
class ImageError(Exception):
    """Raised when the input is None"""
    pass
```

```python
class Frame():
    """
    A class used to store the image the robot saw.

    ...

    Attributes
    ----------
    pose : object
        The pose of the robot in the moment the "picture was taken"
    image : numpy.array
        The matrix of pixels of the image
    timestamp : time object
        The time when the "picture was taken"

    Methods
    -------
    The class doesn't have any methods
    """

    def __init__(self, pose, image, wall_distance, timestamp=None):
        self. pose = pose
        self.image = image
        self.wall_distance = wall_distance
        self.timestamp = time()
```

```python
class FeatureExtractor():
    """
    A class used to implement the methods that are going to extract features to the robot.

    ...

    Attributes
    ----------
    robot : object
        The robot object
    firs_image : object
        The frame object

    Methods
    -------
    step(self)
        Function that controls the pipeline of the feature extraction.
    update(self)
        Function that updates the robot stored image.
    hough_transform(self, image)
        Function that uses the hough transformed to detect lines on the image.
    corner_detect(self, lines)
        Function that detect corners from a list of lines.
    """
    def __init__(self, robot):
        self.robot = robot
        self.first_image = None

    def step(self):
        """
            Function that controls the pipeline of the feature extraction.
        """
        if self.update():
            try:
                lines = self.hough_transform(self.first_image.image)
                corner = self.corner_detect(lines)
                line_distance = self.find_distance(lines)
                if line_distance != -1:
                    self.map_coordinates(line_distance, self.first_image.pose)
                return (lines, corner)
            except:
                raise ImageError
        else:
            raise ImageError

    def update(self):
        """
            Function that updates the robot stored image.
        """
        # get distance between robot and a wall
        wall_distance = min(self.robot.read_ultrassonic_sensors()[3:5])
        # get the (x,y,z) position of the robot at the simulator
        pose = self.robot.get_current_position()
        # get the orientation of the robot (euler angles)
        orientation = self.robot.get_current_orientation()

        # read camera
        image = self.robot.read_vision_sensor()
        image = vrep2array(image[1],image[0])
        # form pose
        pose = Pose(x=pose[0], y=pose[1], orientation=orientation[2])
        # attribute image
        self.first_image = Frame(pose=pose, image=image, wall_distance=wall_distance)

        return True

    def hough_transform(self, image):
        """
            Function that uses the hough transformed to detect lines on the image.

            :input: image - numpy.array of the image pixels
            :return: points - list with the found lines
        """
        # make the image gray (2 channels)
        gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
        # apply the hough transformed
        lines = cv2.HoughLines(gray, 1, np.pi/2, 60)
        points = []
        for  line in lines:
            for rho,theta in line:
                # iterate over lines in order to find the (x_start, y_start) and (x_end, y_end) of the lines
                a = np.cos(theta)
                b = np.sin(theta)
                x0 = a*rho
                y0 = b*rho
                x1 = int(x0 + 1000*(-b))
                y1 = int(y0 + 1000*(a))
                x2 = int(x0 - 1000*(-b))
                y2 = int(y0 - 1000*(a))

                points.append([(x1, y1), (x2, y2)])

        return points

    def corner_detect(self, lines):
        """
            :input: lines - list with the lines found on the image
            :return: corner - list with the (x,y) position of lines intersection
        """
        corner = []
        for line1, line2 in zip(lines, lines[1:]):
            # find the distance of the x points between two lines
            xdiff = (line1[0][0] - line1[1][0], line2[0][0] - line2[1][0])
            # find the distance of the y point between two lines
            ydiff = (line1[0][1] - line1[1][1], line2[0][1] - line2[1][1])

            # calc the determinant
            det = lambda  a, b: a[0] * b[1] - a[1] * b[0]
            div = det(xdiff, ydiff)

            if div == 0:
                continue
            else:
                # calc the point of intercept
                d = (det(*line1), det(*line2))
                x = det(d, xdiff) / div
                y = det(d, ydiff) / div

            corner.append((x,y))

        return corner

    def map_coordinates(self, line_size, robot_pose):
        angle = math.degrees(robot_pose.orientation)
        if angle <= 45 or angle >= -45:
            return
            print("SOMA X: ", line_size)
        elif angle < -45 or angle > -135:
            return
            print("SUBTRAI Y: ", line_size)
        elif angle < -135 or angle > 135:
            return
            print("SUBTRAI X: ", line_size)
        else:
            return
            print("SOMA Y: ", line_size)

    def find_distance(self, lines):
        max_dist = -1
        for line in lines:
            if line[2] < 0.3:
                pass
            else:
                dst = (((line[1][0] - line[0][0])**2) + ((line[1][1] - line[0][1])**2)) ** (1/2)
                if dst > max_dist:
                    max_dist = dst

        return max_dist
```

**2. KMeans Clustering Extractor**

K-means clustering is a method of vector quantization, originally from signal processing, that is popular for cluster analysis in data mining. k-means clustering aims to partition n observations into k clusters in which each observation belongs to the cluster with the nearest mean, serving as a prototype of the cluster. The algorithm has a loose relationship to the k-nearest neighbor classifier, a popular machine learning technique for classification that is often confused with k-means due to the name.

**How It Works:**

Given a set of observations $(x_1, x_2, ..., x_n)$, where each observation is a d-dimensional real vector, k-means clustering aims to partition the n observations into k $(≤ n)$ sets $S = {S_1, S_2, ..., S_k}$ so as to minimize the variance. Formally, the objective is to find:

\begin{align}
\underset{\mathbf{S}} {\operatorname{arg\,min}}  \sum_{i=1}^{k} \sum_{\mathbf x \in S_i} \left\| \mathbf x - \boldsymbol\mu_i \right\|^2 = \underset{\mathbf{S}} {\operatorname{arg\,min}}  \sum_{i=1}^k |S_i| \operatorname{Var} S_i \end{align}

**How we use KMeans for mapping:**

To apply the KMeans for mapping we extract enviroment information from the LIDAR (laser sensors) when the robot were close to some object (we check the robot distance from other objects using the ultrassonic sensor). So, once the LIDAR gives us the (x,y,z) information about the perceived objetc on the scene, it was necessary to perform few steps in order to acquire data:

    1. First we clean the data, extracting just the (x,y) position
    2. We apply the KMeans fit function (specifying the number of clusters)
    3. We extract the centroids of the clusters as the position of objects on the scene

**Why we choose to use KMeans:**

K-means is one of the simplest algorithm which uses unsupervised learning method to solve known clustering issues. It works really well with large datasets and it's not very computational expensive. And this algorithm is guaranteed to converge to a result.

Furthermore, the major drawbacks K-means usually show aren't big problem for us here, since we don't need high quality clusters, or totally free noise information, this is, we can pay this drawbacks in order to have a fast algorithm that is also very easy to implement. And, obviously, we can't forget the main fact, that it reduces the LIDAR data from around 500 position to the set number of clusters we ask.

```python
class KMeansClustering():
    """
    A class used to implement KMeans to extract information from the enviroment.

    ...

    Attributes
    ----------
    robot : object
        The robot object

    Methods
    -------
    step()
        Function that controls the pipeline of the feature extraction.
    kmean_fit()
        Function that fits the model
    centroids_points()
        Function that return cluster centroids
    """
    def __init__(self, robot):
        self.robot = robot

    def step(self):
        """
            Function that controls the pipeline of the feature extraction.

            :return: centroid_pos - position of clusters centroids
        """
        if  min(self.robot.read_ultrassonic_sensors()[0:15]) < 1.0:
            raw_lidar = self.robot.read_laser()
            points = []
            for i in range(0,len(raw_lidar),3):
                points.append([raw_lidar[i], raw_lidar[i+1]])

            clusters = self.kmean_fit(points)
            centroids_pos = self.centroids_points(clusters)

            return centroids_pos
        else:
            return np.array([])

    def kmean_fit(self, points):
        """
            Function that fits the model
            :input: points - lidar data
            :return: y_ - the kmeans object from sklearn
        """
        kmeans = KMeans()
        y_ = kmeans.fit(points)

        return y_

    def centroids_points(self, kmeans_obj):
        """
            Function that return cluster centroids
            :input: kmeans_obj - the kmeans object from sklearn
            :return: position of clusters centroids
        """
        return kmeans_obj.cluster_centers_

```

# Controls robot actions

Simple state machine that organize the robot behavior and store the informations that will be plot.

```python
def state_machine(behavior="follow_wall"):
    # first we create the robot and the walker object
    robot = rb.Robot()
    walker_behavior = Walker(robot)

    # create the FeatureExtractor robot
    #feature_extractor = FeatureExtractor(robot)
    kmeans_extractor = KMeansClustering(robot)

    # instantiate the odometry calculator
    odometry_calculator = Odometry(robot=robot)

    if behavior == "follow_wall":

        # first we find a wall
        while not walker_behavior.find_wall():
            # read image
            try:
                #lin, cor = feature_extractor.step()
                #lines.append(lin)
                #corners.append(cor)
                points = kmeans_extractor.step()
                if points.size != 0:
                    points_kmeans.append(points)
            except ImageError:
                pass
            # calculate the estimate new position after find a wall
            temp_ground = odometry_calculator.ground_truth_updater()
            ground_truth.append((temp_ground.x, temp_ground.y))
            temp_odom = odometry_calculator.odometry_pose_updater()
            odometry.append((temp_odom.x, temp_odom.y))

        robot.stop()
        # calculate the estimate new position after find a wall
        temp_ground = odometry_calculator.ground_truth_updater()
        ground_truth.append((temp_ground.x, temp_ground.y))
        temp_odom = odometry_calculator.odometry_pose_updater()
        odometry.append((temp_odom.x, temp_odom.y))

        # then we align to the wall
        while not walker_behavior.turn_left():
            # read image
            try:
                #lin, cor = feature_extractor.step()
                #lines.append(lin)
                #corners.append(cor)
                points = kmeans_extractor.step()
                if points.size != 0:
                    points_kmeans.append(points)
            except ImageError:
                pass
            # calculate the estimate new position after find a wall
            temp_ground = odometry_calculator.ground_truth_updater()
            ground_truth.append((temp_ground.x, temp_ground.y))
            temp_odom = odometry_calculator.odometry_pose_updater()
            odometry.append((temp_odom.x, temp_odom.y))

        robot.stop()
        # calculate the estimate new position after find a wall
        temp_ground = odometry_calculator.ground_truth_updater()
        ground_truth.append((temp_ground.x, temp_ground.y))
        temp_odom = odometry_calculator.odometry_pose_updater()
        odometry.append((temp_odom.x, temp_odom.y))

        # now we follow the wall
        while True:
            # read image
            try:
                #lin, cor = feature_extractor.step()
                #lines.append(lin)
                #corners.append(cor)
                points = kmeans_extractor.step()
                if points.size != 0:
                    points_kmeans.append(points)
            except ImageError:
                pass
            # calculate the estimate new position after find a wall
            temp_ground = odometry_calculator.ground_truth_updater()
            ground_truth.append((temp_ground.x, temp_ground.y))
            temp_odom = odometry_calculator.odometry_pose_updater()
            odometry.append((temp_odom.x, temp_odom.y))

            # call the function that keeps following the left wall
            walker_behavior.follow_wall()
    else:
        raise Exception("Not implemented!")

```

# Main function - Execute the code here!
Here is a simple signal handler implement in order to make the simulator execution last for a given time period.

```python
import signal
from contextlib import contextmanager

class TimeoutException(Exception): pass

@contextmanager
def time_limit(seconds):
    def signal_handler(signum, frame):
        raise TimeoutException("Timed out!")
    signal.signal(signal.SIGALRM, signal_handler)
    signal.alarm(seconds)
    try:
        yield
    finally:
        signal.alarm(0)

try:
    ground_truth = []
    odometry = []
    lines = []
    corners = []
    points_kmeans = []
    with time_limit(90):
        state_machine()
except TimeoutException as e:
    print("Timed out!")
```

    Connected to remoteApi server.
     Pioneer_p3dx_ultrasonicSensor1 connected.
     Pioneer_p3dx_ultrasonicSensor2 connected.
     Pioneer_p3dx_ultrasonicSensor3 connected.
     Pioneer_p3dx_ultrasonicSensor4 connected.
     Pioneer_p3dx_ultrasonicSensor5 connected.
     Pioneer_p3dx_ultrasonicSensor6 connected.
     Pioneer_p3dx_ultrasonicSensor7 connected.
     Pioneer_p3dx_ultrasonicSensor8 connected.
     Pioneer_p3dx_ultrasonicSensor9 connected.
     Pioneer_p3dx_ultrasonicSensor10 connected.
     Pioneer_p3dx_ultrasonicSensor11 connected.
     Pioneer_p3dx_ultrasonicSensor12 connected.
     Pioneer_p3dx_ultrasonicSensor13 connected.
     Pioneer_p3dx_ultrasonicSensor14 connected.
     Pioneer_p3dx_ultrasonicSensor15 connected.
     Pioneer_p3dx_ultrasonicSensor16 connected.
     Vision sensor connected.
     Laser connected.
     Left motor connected.
     Right motor connected.
     Robot connected.
    Timed out!

# Some Results
In order to show how the implemented code works we are going to do and discuss three experiments.

## First Experiment

In the first experiment the robot were placed as shown in the following image:

**The obtained results following, and discussions about them are bellow the grafics.**

```python
# we take the negative in order to simulate the VREP orientation
odometry_x_1 = [-i[0] for i in odometry]
odometry_y_1 = [-i[1] for i in odometry]
ground_truth_x_1 = [-i[0] for i in ground_truth]
ground_truth_y_1 = [-i[1] for i in ground_truth]
```

```python
plt.figure()
plt.title('Odomotry x Ground Truth for Experiment 1')
plt.scatter(odometry_x_1, odometry_y_1, c='y',label='Odometry')
plt.scatter(ground_truth_x_1, ground_truth_y_1, c='b',label='Ground Truth')
plt.legend()
plt.show()
```

### Discussion - Odometry - Experiment 1

The odometry result for the first experiment was very bad, as we can see in the image above. Clearly, after the first turn the odometry got fully lost, even making the robot think he is going to a fully opposite  direction. This bad result can be possibly explained by the lots of turns made on the trajectory and also, the simplicity of the kinematic model used.

We can observe that the way the robot turn it's all but stable and delicate, due to the way the 'wall-follower' controller was implemented, in order to turn or even to follow the wall the robot does a lot of micro turns and change his orientation and velocity of the wheels a lot. This situation along a kinematic that is very sensitive for angle changes, are the main reason for the obtained results.

```python
final_mapping_x_1 = []
final_mapping_y_1 = []
for array_ in points_kmeans:
    for point in array_:
        final_mapping_x_1.append(-point[0])
        final_mapping_y_1.append(-point[1])
```

```python
plt.figure()
plt.title('Mapping for Experiment 1')
plt.scatter(final_mapping_x_1, final_mapping_y_1)
plt.show()
```

### Discussion - Mapping - Experiment 1

The result obtained from the mapping algorithm can be classified, in an optimistic view, as OK!. As we knew, and had already commented, the K-Means is very sensitive to noise, which is easy to see in the above figure. However, as we had discussed above, we don't need to make the best map in the world, and the obtained map, even with all the noisy, can still be usefull for the robot, identifying walls and other objects in the map.

## Second Experiment

In the second experiment the robot were placed as shown in the following image:

**The obtained results following, and discussions about them are bellow the grafics.**

```python
# we take the negative in order to simulate the VREP orientation
odometry_x_2 = [-i[0] for i in odometry]
odometry_y_2 = [-i[1] for i in odometry]
ground_truth_x_2 = [-i[0] for i in ground_truth]
ground_truth_y_2 = [-i[1] for i in ground_truth]
```

```python
plt.figure()
plt.title('Odomotry x Ground Truth for Experiment 2')
plt.scatter(odometry_x_2, odometry_y_2, c='y',label='Odometry')
plt.scatter(ground_truth_x_2, ground_truth_y_2, c='b',label='Ground Truth')
plt.legend()
plt.show()
```

### Discussion - Odometry - Experiment 2

The odometry result for the second experiment was so much better than the first one, as we can see in the image above. The only problem we can observe lives after the first turn, as we expected, but, that time, the robot is abble to recover his orientation and recover his movement.

The other main problem we can observe is the greater than normal displacement perceived by odometry.

```python
final_mapping_x_2 = []
final_mapping_y_2 = []
for array_ in points_kmeans:
    for point in array_:
        final_mapping_x_2.append(-point[0])
        final_mapping_y_2.append(-point[1])
```

```python
plt.figure()
plt.title('Mapping for Experiment 2')
plt.scatter(final_mapping_x_2, final_mapping_y_2)
plt.show()
```

### Discussion - Mapping - Experiment 2

The result obtained from the mapping algorithm is, just like in odometry, better than the first one. Again, there is a lot of noise on the map, a clear point of improvement, but is kind of easy to identify the objects seen on the scene at this map. So, as we commented for the first experiment, it could be usefull for the robot localization.

## Third Experiment

In the first experiment the robot were placed as shown in the following image:

**The obtained results following, and discussions about them are bellow the grafics.**

```python
# we take the negative in order to simulate the VREP orientation
odometry_x_3 = [-i[0] for i in odometry]
odometry_y_3 = [-i[1] for i in odometry]
ground_truth_x_3 = [-i[0] for i in ground_truth]
ground_truth_y_3 = [-i[1] for i in ground_truth]
```

```python
plt.figure()
plt.title('Odomotry x Ground Truth for Experiment 3')
plt.scatter(odometry_x_3, odometry_y_3, c='y')
plt.scatter(ground_truth_x_3, ground_truth_y_3, c='b')
plt.show()
```

### Discussion - Odometry - Experiment 3

The odometry result for the third experiment was very close to the one obtained at the second one, but, even better. For the first time, the robot didn't suffer to much after make a turn, keeping a good perception of his own location.

The main problem we can observe is the greater than normal displacement perceived by odometry, in both directions.

```python
final_mapping_x_3 = []
final_mapping_y_3 = []
for array_ in points_kmeans:
    for point in array_:
        final_mapping_x_3.append(-point[0])
        final_mapping_y_3.append(-point[1])
```

```python
plt.figure()
plt.title('Mapping for Experiment 3')
plt.scatter(final_mapping_x_3, final_mapping_y_3)
plt.show()
```

### Discussion - Mapping - Experiment 3

The map obtained at the third experiment, was, by far, the best one. As soon we compare the map above with the image of the robot start location, the patterns clearly indicate where there are objects and where there is free space to the robot move. Again, there is a bunch of noise, but nothing that can disturb the robot localization.

## Conclusions And Final Observations

Once the project is done, it is clear that there are a number of improvements to be implemented. For the kinematic model, more sophisticated solutions, as visual odometry, visual slam, or even implement a kalman filter for odometry would give us much better results. For the mapping, even though we have obtained useful results, the K-Means suffers with to much noise, separating us from high quality results. In order to achieve better mapping results, we could implement feature extraction from images (like we were trying, but we failed), or state of art algorithms, like the Bresenham's line drawing algorithm, or the raincast, among other famous ones.

Personally speaking, I believe the results were far below expectations. The learning curve for understanding the robot pipeline was longer than expected. Also, I believe a lot of time was spent on minor issues for this project, such as the implementation of the fuzzy controller. For future projects, better programming and preparation is needed to achieve better results.

```python

```


#  CS109B Data Science 2: Advanced Topics in Data Science
## Lecture 3 - Coding Environment Setup and review of `statsmodels`
###  Notebook B

**Harvard University**

**Spring 2021**

**Instructors:** Mark Glickman, Pavlos Protopapas, and Chris Tanner

**Additional Instructor:** Eleni Kaxiras

*Content:* Eleni Kaxiras and Will Claybaugh

---

```
## RUN THIS CELL TO PROPERLY HIGHLIGHT THE EXERCISES
import requests
from IPython.core.display import HTML
styles = requests.get("https://raw.githubusercontent.com/Harvard-IACS/2019-CS109B/master/content/styles/cs109.css").text
HTML(styles)
```

```
import numpy as np
from scipy.interpolate import interp1d
import matplotlib.pyplot as plt
import pandas as pd

%matplotlib inline
```

## Learning Goals

By the end of this lab, you should be able to:
* use `np.linalg.vander`
* use the weird R-style formulas in `statsmodels`
* practice least-squares regression in `statsmodels`

### Basis Functions

In our models we can use various types of functions as basis functions. Strictly speaking, in linear algebra where a basis for a subspace S of $\mathbb{R}^n$ is a set of vectors that spans S and is linearly independent. As a reminder, a set of vectors $\textbf{v}_1, \textbf{v}_2, ..., \textbf{v}_k$ are considered linearly independent if they cannot be written as a linear combination of each other, such that, if: $c_1\textbf{v}_1+c_2\textbf{v}_2+ ...+ c_k\textbf{v}_k = \textbf{0}$ then $c_1,c_2,...,c_k$ are all zero.

In data science where we have lots of imperfect data (with errors), as well as imperfect computers (with round-off errors), when we substitute their values into the matrices we almost always get column degeneracy, meaning, some of our columns become linear combinations of each other. Especially so if we use the monomial basis and go beyond ~5,6 degree of the polynomial.

Examples are:

- Monomials such as $x,x^2,x^4,x^5$
- Sigmoid/ReLU functions (neural networks)
- Fourier functions
- Wavelets
- Splines

The matrix produced when we substitute the values of our data into the basis functions is called the *design matrix*.

### Linear/Polynomial Regression

We will use the `diabetes` dataset.

Variables are:
- subject:   subject ID number
- age:       age diagnosed with diabetes
- acidity:   a measure of acidity called base deficit
Response:
- y:         natural log of serum C-peptide concentration

*Original source is Sockett et al. (1987) mentioned in Hastie and Tibshirani's book
"Generalized Additive Models".*

Reading the data in Pandas:

```
diab = pd.read_csv("data/diabetes.csv")
diab.head()
```

### Create the design matrix for a fictitious dataset
Let's keep just the `age` feature and create some columns of our own. Let's see how good this matrix is before we create the design matrix.

```
diab_age = diab[['age']].copy()
diab_age['age2'] = diab_age.apply(lambda row: row['age']**2, axis=1)
diab_age['random'] = np.random.normal(0,1,len(diab_age))
diab_age['same'] = diab_age['age']
diab_age.head()
```

```
A = diab_age.to_numpy(copy=True)
A[:5]
```

Let's check if the columns of A are linearly independent by using some linear algebra methods from `numpy.linalg` and `sympy`.

```
from numpy.linalg import matrix_rank
matrix_rank(A)
```

```
# check out which rows are linearly independent
import sympy
_, inds = sympy.Matrix(A).T.rref()
inds
```

```
np.linalg.cond(A)
```

### Create the design matrix for `age` using a polynomial basis
Let's keep just the `age` feature again and create the design matrix using a polynomial of degree `n`. First we will use the basic `numpy` formula `vander()`.

```
vand = np.vander(diab_age.age, 2, increasing=True)
vand[:3], vand.shape
```

```
## To our point why the Vandermonde matrix is usually ill-conditioned,
## find the condition number of this matrix
np.linalg.cond(vand), matrix_rank(vand)
```

Exercise: Vandermonde matrix

Change the degree of the polynomial and comment on what happens to the condition and rank of the matrix.

```
vand = np.vander(diab_age.age, 8, increasing=True)
vand[:3], vand.shape
```

```
## To our point why the Vandermonde matrix is usually ill-conditioned,
## find the condition number of this matrix
np.linalg.cond(vand), matrix_rank(vand)
```

### Linear/Polynomial regression with statsmodels.

As you remember from 109a, we have two tools for Linear Regression:
- `statsmodels` [https://www.statsmodels.org/stable/regression.html](https://www.statsmodels.org/stable/regression.html), and
- `sklearn`[https://scikit-learn.org/stable/index.html](https://scikit-learn.org/stable/index.html)

Previously, in this notebook, we worked from a vector of target values and a design matrix we built ourself. In 109a we used e.g. `sklearn`'s PolynomialFeatures to build the matrix. Now we will look at `statsmodels` which allows users to fit statistical models using R-style **formulas**. They build the target value and design matrix for you.

**Note:** Categorical features (e.g. let's say we had a categorical feature called Region, are designated by `C(Region)`), polynomial features (e.g. age) are entered as `np.power(age, n)` where `n` is the degree of the polynomial **OR** `np.vander(age, n, increasing=True)`.

```
# Example: if our target variable is 'Lottery', while 'Region' is a categorical predictor and all the others are numerical:
df = dta.data[['Lottery', 'Literacy', 'Wealth', 'Region']]

formula='Lottery ~ Literacy + Wealth + C(Region) + Literacy * Wealth'
```

For more on these formulas see:

- https://www.statsmodels.org/stable/examples/notebooks/generated/formulas.html
- https://patsy.readthedocs.io/en/latest/overview.html

```
import statsmodels.formula.api as smf

model1 = smf.ols('y ~ age', data=diab)
fit1_lm = model1.fit()
```

Let's build a dataframe to predict values on (sometimes this is just the test or validation set). Very useful for making pretty plots of the model predictions - predict for TONS of values, not just whatever's in the training set.

```
x_pred = np.linspace(0.5,20,100)

predict_df = pd.DataFrame(data={"age":x_pred})
predict_df.head()
```

Use `get_prediction(<data>).summary_frame()` to get the model's prediction (and error bars!)

```
prediction_output = fit1_lm.get_prediction(predict_df).summary_frame()
prediction_output.head()
```

Plot the data, the fitted model, the **confidence intervals**, and the *prediction intervals*. For more on how `statsmodels` calculates these intervals see: https://www.statsmodels.org/stable/_modules/statsmodels/regression/_prediction.html

```
ax1 = diab.plot.scatter(x='age',y='y',c='brown',title="Diabetes data with least-squares linear fit")
ax1.set_xlabel("Age at Diagnosis")
ax1.set_ylabel("Log C-Peptide Concentration")

ax1.plot(predict_df.age, prediction_output['mean'],color="green")
ax1.plot(predict_df.age, prediction_output['mean_ci_lower'], color="blue",linestyle="dashed")
ax1.plot(predict_df.age, prediction_output['mean_ci_upper'], color="blue",linestyle="dashed")
ax1.plot(predict_df.age, prediction_output['obs_ci_lower'], color="green",linestyle="dashdot")
ax1.plot(predict_df.age, prediction_output['obs_ci_upper'], color="green",linestyle="dashdot");
```

Breakout Room Exercise

- Fit a 3rd degree polynomial model to predict `y` using only `age` and
- Plot the model and its confidence intervals.
- Change the degree of your polynomial and see what happens to the fitted curve.
- Does our model have an intercept? *Note*: we can discover the existence or not of an intercept in our model by running:
```
model_name.params
```

```
# your answer here

```

```
# solution

```

Vandermonde matrix in formulas

It's easier to build higher order polynomials using `np.vandrer()`.

```
formula = "y ~ np.vander(age, 6, increasing=True) -1"
fit3_lm = smf.ols(formula=formula, data=diab).fit()
```

```
fit3_lm.params
```

```
## To our point why the Vandermonde matrix is usually ill-conditioned,
# find the condition number of this matrix
np.linalg.cond(np.vander(predict_df.age, 6, increasing=True))
```

```
# solution
poly_predictions = fit3_lm.get_prediction(predict_df).summary_frame()
poly_predictions.head()
```

```
# solution
x_pred = np.linspace(0.5,15,100)
predict_df = pd.DataFrame(data={"age":x_pred})

ax2 = diab.plot.scatter(x='age',y='y',c='Red',title="Diabetes data with least-squares cubic fit")
ax2.set_xlabel("Age at Diagnosis")
ax2.set_ylabel("Log C-Peptide Concentration")

ax2.plot(predict_df.age, poly_predictions['mean'],color="green")
ax2.plot(predict_df.age, poly_predictions['mean_ci_lower'], color="blue",linestyle="dashed", label='confidence interval')
ax2.plot(predict_df.age, poly_predictions['mean_ci_upper'], color="blue",linestyle="dashed");
ax2.legend();
```

Discussion

#### QR decomposition (**Beyond the scope of this class**)

As you know, to find the parameters of our model, we may try to solve the so-called *normal equations*, which, written in matrix form, are:

\begin{equation}
    (\boldsymbol{A^T}\cdot \boldsymbol{A}) \cdot \boldsymbol{b} = \boldsymbol{A} \cdot \boldsymbol{y}
\end{equation}

The direct solution is $\hat{\boldsymbol{b}}=(\boldsymbol{A}^T\cdot \boldsymbol{A})^{-1}\cdot \boldsymbol{A}^T \cdot \boldsymbol{y}$

Solving the least-squares problem directly via the normal equations is susceptible to roundoff error when the condition of the matrix $\boldsymbol{A}$ is large. An alternative technique involves QR decomposition (details in any good linear algebra book). `statsmodels` lets you use this technique via a parameter in the `.fit`:
```
   .fit(method='qr')
```

#### Let's try with QR now

```
formula = "y ~ np.vander(age, 6, increasing=True) -1"
fit3_lm = smf.ols(formula=formula, data=diab).fit(method='qr')
```

```
fit3_lm.params
```

```
# solution
poly_predictions = fit3_lm.get_prediction(predict_df).summary_frame()
poly_predictions.head()
```

```
# solution
ax2 = diab.plot.scatter(x='age',y='y',c='Red',title="Diabetes data with least-squares cubic fit")
ax2.set_xlabel("Age at Diagnosis")
ax2.set_ylabel("Log C-Peptide Concentration")

ax2.plot(predict_df.age, poly_predictions['mean'],color="green")
ax2.plot(predict_df.age, poly_predictions['mean_ci_lower'], color="blue",linestyle="dashed", label='confidence interval')
ax2.plot(predict_df.age, poly_predictions['mean_ci_upper'], color="blue",linestyle="dashed");
ax2.legend();
```


# 数学优化建模简介

## 目标和先决条件

该建模示例的目的是在混合整数规划（MIP）问题的公式化中介绍关键元素。对于MIP问题公式的每个组成部分，我们提供描述，相关的Python代码以及描述该组成部分的数学符号。

为了完全理解此部分的内容，读者应：

* 熟悉Python。
* 有相关学科背景，包括但不限于工程、计算机科学、经济学、统计学等各类“硬核”科学，或包含定量模型和方法的任一学科。

读者还应查询 Gurobi Python API 的相关 [文档](https://www.gurobi.com/resources/?category-filter=documentation)。此外，在《混合整数线性编程的系列教程》视频中详细介绍了此文档的内容。

你可以在 [这里](https://www.gurobi.com/resource/tutorial-mixed-integer-linear-programming/) 观看视频教程

**注意：** 你可以通过单击 [此处](https://github.com/arvinxx/gurobi-and-mathematical-modeling/archive/master.zip) 下载包含此示例和其他示例的代码。为了正确运行此 Jupyter Notebook，您必须具有 Gurobi 许可证。如果您没有，则可以**商业用户身份**申请 [试用许可证](https://www.gurobi.com/downloads/request-an-evaluation-license/)，或以**学术用户身份**下载 [免费许可证](https://www.gurobi.com/academia/academic-program-and-licenses)。

## 问题描述

假如有一家咨询公司，它有三个空缺职位：Tester（测试）、Java Developer（Java开发）和 Architect（架构师）。这三个职位的候选人是：Carlos、Joe 和 Monika。
这家咨询公司对每个候选人进行了能力测试，以评估他们完成每项职位的能力。这些测试的结果称为 「匹配分数」（matching scores）。假设一个职位只能分配一个候选人，并且最多可以为一个候选人分配一个职位。

我们的目标是：确定候选人和职位的分配，以使每个职位都得到满足。每个候选人最多分配一个岗位，分配后的总「匹配得分」最大。

## 数学优化

数学优化（也称为数学编程）是一种声明性方法，其中建模者制定了一个优化问题，该问题捕获了复杂决策问题的关键特征。然后 Gurobi 使用最先进的数学和计算机科学技术来解决这一类数学优化问题。

数学优化模型具有五个组成部分：

* 数据集（Sets）
* 参数(Parameters)
* 决策变量(Decision variables)
* 约束（Constraints）
* 目标函数（Objective function(s)）

以下 Python 代码导入了 Gurobi 模块，并将 `GRB` 类导入了主命名空间。

```python
import gurobipy as gp
from gurobipy import GRB
```

## 候选人分配问题

### Data

列表 $R$ 包含三个候选人的名称：Carlos，Joe 和 Monika。
列表 $J$ 包含职位的名称：Tester（测试），Java Developer（Java开发） 和 Architect（架构）。

$r \in R$：索引和候选人集。候选人 $r$ 属于候选人集 $R$。

$j \in j$：索引和职位集。职位 $j$ 属于职位集 $J$。

```python
# 候选人集和职位集
R = ['Carlos', 'Joe', 'Monika']
J = ['Tester', 'JavaDeveloper', 'Architect']
```

以下匹配得分表中列出了每种候选人参与每种职位的能力：

对于每个候选人 $r$ 和职位 $j$，都有一个对应的匹配得分 $s$。匹配的分数 $s$ 只能接受 0 到 100 之间的值。

也就是说，对于 $r \in R $ 和 $j \in J$ ，都有 $s_{r,j} \in [0,100]$ 。

我们使用 Gurobi Python 的`multidict` 函数来初始化一个或多个字典。该函数以字典为参数。键代表候选人和职位的可能组合。

```python
# 匹配分数数据
combinations, scores = gp.multidict({
    ('Carlos', 'Tester'): 53,
    ('Carlos', 'JavaDeveloper'): 27,
    ('Carlos', 'Architect'): 13,
    ('Joe', 'Tester'): 80,
    ('Joe', 'JavaDeveloper'): 47,
    ('Joe', 'Architect'): 67,
    ('Monika', 'Tester'): 53,
    ('Monika', 'JavaDeveloper'): 73,
    ('Monika', 'Architect'): 47
})
```

以下构造函数创建一个空的 Model 对象 `m` 。我们通过传递字符串 `"RAP"` 作为参数来指定模型名称。 Model 对象 `m` 包含一个优化问题。该优化问题由一组决策变量，一组约束和目标函数组成。

```python
# 声明并初始化模型
m = gp.Model('RAP')
```

    Using license file /Users/arvinxx/gurobi.lic
    Academic license - for non-commercial use only - expires 2021-02-01

## 决策变量

为了解决此分配问题，我们需要确定将哪个候选人分配给哪个职位。我们为职位的每种可能候选人分配引入一个决策变量。因此，我们有 9 个决策变量。

为了简化模型公式的数学符号，我们定义了以下候选人和职位的指标:

例如，$x_{2,1}$ 是与将候选人 Joe 分配给职位岗位 Tester 的决策变量。因此，如果$r \in R$ 被分配给 $j \in J$，则决策变量 $x_{r,j}$ 等于1，否则为0。

`Model.addVars()` 方法为 Model 对象创建决策变量。此方法返回包含新创建的决策变量。该变量类型为 Gurobi 专有的 `tupledict` 对象。我们将 `combinations` 对象作为指定变量索引的第一个参数。`name` 关键字用于为新创建的决策变量命名。默认情况下，变量被假定为非负数。

```python
# 为RAP模型创建决策变量
x = m.addVars(combinations, name="assign")
```

## 职位限制

现在我们讨论与职位相关的约束。这些约束条件需要确保每个职位都恰好由一个候选人安排。

Tester 职位的约束要求将候选人1（Carlos），候选人2（Joe）或候选人3（Monika）分配给该 职位。这对应于以下约束。

约束 (Tester=1)

$$
x_{1,1} + x_{2,1} + x_{3,1} = 1
$$

类似地，Java职位和架构师职位的约束可以定义如下。

约束 (Java Developer = 2)

$$
x_{1,2} + x_{2,2} + x_{3,2} = 1
$$

约束 (Architect = 3)

$$
x_{1,3} + x_{2,3} + x_{3,3} = 1
$$

职位约束由下表的列定义。

通常情况下，可以将 Tester 职位的约束定义如下。

$$
x_{1,1} + x_{2,1} + x_{3,1} = \sum_{r=1}^{3 } x_{r,1} =  \sum_{r \in R} x_{r,1} = 1
$$
可以以类似简洁的方式定义所有职位约束。对于$j \in J$，取所有候选人的决策变量的总和。我们可以如下编写相应的职位约束。

$$
\sum_{r \in R} x_{r,j} = 1
$$

Gurobi 的 `Model.addConstrs()` 方法定义了 Model 对象 `m` 的职位约束。此方法返回职位约束的变量，该变量类型也为 `tupledict`。

```python
# 创建职位约束
jobs = m.addConstrs((x.sum('*',j) == 1 for j in J), name='job')
```

此方法的第一个参数 `x.sum(‘*’, j)` 是 sum 方法，它定义职位约束的 LHS 如下：

对于职位 $J$ 中的每个职位 $j$ ，取所有候选人上决策变量的总和。`==` 定义了一个相等约束，数字 1 是这些约束的RHS。

这些约束意味着每个职位只能分配一个候选人。第二个参数是此类约束的名称。

## 候选人约束

候选人约束需要确保至多为每个候选人分配一个职位。也就是说，可能不是所有的候选人都被分配了。
比如这样约束：Carlos 最多分配一个职位: 职位1(Tester)、职位2 (Java)或职位3(架构师)。这个约束如下书写：

约束 (Carlos=1)

$$
x_{1, 1} + x_{1, 2} + x_{1, 3}  \leq 1.
$$

这个约束使它们的值小于或等于1，以允许 Carlos 不被分配到任何职位的可能性。同样，对候选人 Joe 和 Monika 的约束可以定义如下:

约束 (Joe=2)

$$
x_{2, 1} + x_{2, 2} + x_{2, 3}  \leq 1.
$$

约束 (Monika=3)

$$
x_{3, 1} + x_{3, 2} + x_{3, 3}  \leq 1.
$$

请注意，候选人约束是由下表的行定义的。

候选人 Carlos 的约束可以定义如下。

$$
x_{1, 1} + x_{1, 2} + x_{1, 3} = \sum_{j=1}^{3 } x_{1,j} = \sum_{j \in J} x_{1,j} \leq 1.
$$

同样，每个约束都可以用简洁的方式书写。对于$r \in R$，对所有职位的决策变量求和。我们可以将相应的候选人约束写如下。

$$
\sum_{j \in J} x_{r,j} \leq  1.
$$

Gurbo 的 `Model.addConstrs()` 方法定义了 Model 对象 `m` 的候选人约束。
此方法的第一个参数 `x.sum(r, ‘*’)` 是求和方法，它定义候选人约束的LHS如下： 对于候选人集 $R$ 中的每个候选人$r$，对所有职位进行决策变量的求和。`<=`定义了一个更少或相等的约束，约束的 RHS 是 1。这些限制意味着每个候选人最多只能分配1个职位。

第二个参数是此类约束的名称。

```python
# 创建候选人约束
resources = m.addConstrs((x.sum(r,'*') <= 1 for r in R), name='resource')
```

## 目标函数

目标函数是使满足职位和候选人约束的职位的总匹配得分最大化。

对于测试人员职位，如果分配了候选人Carlos，其匹配分数为 $53x_{1,1}$, 如果分配了候选人 Joe，匹配分数为 $80x_{2,1}$, 如果分配了候选人 Monika，匹配分数为  $53x_{3,1}$。

因此，Tester职位的匹配分数如下，其中该总和中的决策变量只有一项值为 1。

$$
53x_{1,1} + 80x_{2,1} + 53x_{3,1}.
$$

类似地，Java开发人员和架构师职位的匹配分数定义如下。Java开发人员职位的匹配分数是:

$$
27x_{1, 2} + 47x_{2, 2} + 73x_{3, 2}.
$$

架构师职位的匹配分数是:

$$
13x_{1, 3} + 67x_{2, 3} + 47x_{3, 3}.
$$

总匹配得分为下表中每个单元格的总和。

目标是使分配的总匹配分数最大化。因此，目标函数定义如下。

\begin{equation}
\text{Maximize} \quad (53x_{1,1} + 80x_{2,1} + 53x_{3,1}) \; +
\end{equation}

\begin{equation}
\quad (27x_{1, 2} + 47x_{2, 2} + 73x_{3, 2}) \; +
\end{equation}

\begin{equation}
\quad (13x_{1, 3} + 67x_{2, 3} + 47x_{3, 3}).
\end{equation}

目标函数中括号中的每一项可以表示为:

\begin{equation}
(53x_{1,1} + 80x_{2,1} + 53x_{3,1}) = \sum_{r \in R} s_{r,1}x_{r,1}.
\end{equation}

\begin{equation}
(27x_{1, 2} + 47x_{2, 2} + 73x_{3, 2}) = \sum_{r \in R} s_{r,2}x_{r,2}.
\end{equation}

\begin{equation}
(13x_{1, 3} + 67x_{2, 3} + 47x_{3, 3}) = \sum_{r \in R} s_{r,3}x_{r,3}.
\end{equation}

因此，目标函数可以简写为：

\begin{equation}
\text{Maximize} \quad \sum_{j \in J} \sum_{r \in R} s_{r,j}x_{r,j}.
\end{equation}

Gurobi 的 `Model.setobobjective()` 方法定义了 Model 对象 `m` 的目标函数。

```python
# 目标:使所有职位的总匹配分数最大化
m.setObjective(x.prod(scores), GRB.MAXIMIZE)
```

第一个参数指定了目标函数的表达式。

> 注意：匹配的分数参数 score 和分配决策变量 x 都是在 `combinations` 的键上定义的。因此，我们可以使用`x.prod(score)` 的方式,得到 score 矩阵与 x 变量矩阵的元素乘法的和。

第二个参数`GRB.MAXIMIZE`， `MAXIMIZE`，是优化核心目标。在这种情况下，我们想要 **最大化** 所有分配的总匹配分数。

我们使用 Gurobi 的 `write()` 方法将模型公式写入 `RAP.lp` 文件。

```python
# 保存模型以供查看
m.write('RAP.lp')
```

我们使用 Gurobi 的 `optimize()` 方法来为模型对象 `m` 定义的问题求解。

```python
# 运行优化引擎
m.optimize()
```

    Gurobi Optimizer version 9.1.0 build v9.1.0rc0 (mac64)
    Thread count: 6 physical cores, 12 logical processors, using up to 12 threads
    Optimize a model with 6 rows, 9 columns and 18 nonzeros
    Model fingerprint: 0xb6602fb2
    Coefficient statistics:
      Matrix range     [1e+00, 1e+00]
      Objective range  [1e+01, 8e+01]
      Bounds range     [0e+00, 0e+00]
      RHS range        [1e+00, 1e+00]
    Presolve time: 0.00s
    Presolved: 6 rows, 9 columns, 18 nonzeros

    Iteration    Objective       Primal Inf.    Dual Inf.      Time
           0    4.6000000e+32   1.800000e+31   4.600000e+02      0s
           5    1.9300000e+02   0.000000e+00   0.000000e+00      0s

    Solved in 5 iterations and 0.01 seconds
    Optimal objective  1.930000000e+02

使用 Gurobi 的`Model.getVars()` 方法来检索模型对象 `m` 中所有变量。

`.x` 属性用于查询当前可行解下的变量值，`.varName` 属性用于获得决策变量的名称。

```python
# 显示决策变量的值
for v in m.getVars():
    if v.x > 1e-6:
        print(v.varName, v.x)

# 显示最佳总匹配分数
print('匹配得分总分: ', m.objVal)
```

    assign[Carlos,Tester] 1.0
    assign[Joe,Architect] 1.0
    assign[Monika,JavaDeveloper] 1.0
    匹配得分总分:  193.0

最佳分配方案是：

* Carlos -> Tester, 匹配得分为 53
* Joe -> Architect, 匹配得分为 of 67
* Monika ->  Java Developer, 匹配得分为 73.

最大匹配得分的总分为193分。

## 具有预算约束的候选人分配问题

现在，假设存在一个固定成本 $C_{r,j}$，将 $r \in R$ 分配给$j \in J$。另外一个条件则是：可以用于职位分配的预算 $B$ 是有限的。

将Carlos、Joe 或 Monika 分配给任何职位的成本分别为 $\$1000$、$\$2000$ 和 $\$3000$。可用预算为$\$5000$。

### 数据

列表 $R$ 包含三个候选人的名称: Carlos、Joe 和 Monika。
列表 $J$ 包含职位职位的名称：Tester，Java Developer 和 Architect。

我们使用Gurobi的 `multidict` 方法初始化两个字典：
* `scores` 定义每种候选人和职位组合的匹配分数。
* `cost` 定义为职位分配候选人相关的固定成本。

```python
# 候选人和职位集
R = ['Carlos', 'Joe', 'Monika']
J = ['Tester', 'JavaDeveloper', 'Architect']

# 匹配分数数据
# cost 以千美元为单位
combinations, scores, costs = gp.multidict({
    ('Carlos', 'Tester'): [53, 1],
    ('Carlos', 'JavaDeveloper'): [27, 1],
    ('Carlos', 'Architect'): [13,1],
    ('Joe', 'Tester'): [80, 2],
    ('Joe', 'JavaDeveloper'): [47, 2],
    ('Joe', 'Architect'): [67, 2],
    ('Monika', 'Tester'): [53, 3] ,
    ('Monika', 'JavaDeveloper'): [73, 3],
    ('Monika', 'Architect'): [47, 3]
})

# 可用预算（千美元）
budget = 5
```

以下构造函数创建一个空的 Model 对象 `m` 。我们通过传递字符串 `"RAP2"` 作为参数来指定模型名称。 Model 对象 `m` 包含一个优化问题。该优化问题由一组决策变量，一组约束和目标函数组成。

```python
# 声明和初始化模型
m = gp.Model('RAP2')
```

### 决策变量

如果 $r \in R$ 被分配给 $j \in J$，则决策变量 $x_{r,j}$ 为1，否则为0。

`Model.addVars()` 方法定义模型对象“ m”的决策变量。

由于存在预算限制，因此可能不会安排所有职位。为了解决这个问题，我们定义了一个新的决策变量，该变量指示是否安排了职位。

如果职位 $j \in J$ 没有被安排，$g_{j}$ = 1，否则为0。这个变量是一个中间变量，它指示一个职位不被安排。

***备注：*** 对于 `RAP` 的先前公式，我们将赋值变量定义为非负且连续的，这是`Model.addVars()` 方法的 `vtype` 参数的默认值。

然而，在 RAP2 中，由于我们添加到模型中的预算约束，我们需要显式地将这些变量定义为布尔变量。在 `addvars()` 中添加 `vtype=GRB.BINARY` 参数，将该决策变量定义为布尔类型。

```python
# 为 RAP2 模型创建决策变量
x = m.addVars(combinations, vtype=GRB.BINARY, name="assign")

# 为 RAP2 模型创建差距变量
g = m.addVars(J, name="gap")
```

### 职位限制

由于我们预算有限，无法为职位分配候选人，因此可能无法安排所有职位。对于职位约束，存在两种可能性，即该职位有候选人安排，或者没有被安排。因此我们需要声明一个变量 $g_j$ 捕获后一种可能性。因此，职位约束如下。

对于 $j \in J$，必须给职位分配一个候选人，或者必须将相应的 $g_j$ 变量设为1:

$$
\sum_{r \: \in \: R} x_{r,\; j} + g_{j} = 1.
$$

```python
# 创建职位限制
jobs = m.addConstrs((x.sum('*',j) + g[j]  == 1 for j in J), name='job')
```

### 候选人限制

候选人约束需要确保至多为每个候选人分配一个职位。也就是说，可能不是所有的候选人都被分配了。因此，将候选人约束写如下。

对于$r \in R$，最多只能给该候选人分配一个职位:

$$
\sum_{j \: \in \: J} x_{r,\; j} \leq 1.
$$

```python
# 创建候选人约束
resources = m.addConstrs((x.sum(r,'*') <= 1 for r in R), name='resource')
```

### 预算约束

该约束条件确保分配候选人以满足职位要求的成本不超过可用预算。任务和预算的成本以千美元计。

如果分配了候选人 Carlos，则安排 Tester 职位的成本为 $1x_{1,1}$，如果分配了候选人 Joe，则成本为 $2x_{2,1}$，如果为候选人 Monika，则为 $3x_{3,1}$。

因此，完成Tester职位的成本如下，其中该总和中的至多一项不为零。

$$
1x_{1,1} + 2x_{2,1} + 3x_{3,1}.
$$

同样，安排 Java Developer 和 Architect 职位的成本定义如下。

安排 Java Developer 职位的成本是：

$$
1x_{1, 2} + 2x_{2, 2} + 3x_{3, 2}.
$$

安排 Architect 职位的成本为：

$$
1x_{1, 3} + 2x_{2, 3} + 3x_{3, 3}.
$$

因此，填补职位空缺的总成本应该小于或等于可用的预算。

\begin{equation}
(1x_{1,1} + 2x_{2,1} + 3x_{3,1}) \; +
\end{equation}

\begin{equation}
(1x_{1, 2} + 2x_{2, 2} + 3x_{3, 2}) \; +
\end{equation}

\begin{equation}
(1x_{1, 3} + 2x_{2, 3} + 3x_{3, 3}) \leq 5
\end{equation}

预算约束中的括号中的每一项可以表示为:

\begin{equation}
(1x_{1,1} + 2x_{2,1} + 3x_{3,1}) = \sum_{r \in R} C_{r,1}x_{r,1}.
\end{equation}

\begin{equation}
(1x_{1, 2} + 2x_{2, 2} + 3x_{3, 2}) = \sum_{r \in R} C_{r,2}x_{r,2}.
\end{equation}

\begin{equation}
(1x_{1, 3} + 2x_{2, 3} + 3x_{3, 3}) = \sum_{r \in R} C_{r,3}x_{r,3}.
\end{equation}

因此，预算约束可以简写为:

\begin{equation}
\sum_{j \in J} \sum_{r \in R} C_{r,j}x_{r,j} \leq B.
\end{equation}

代码如下：

```python
budget = m.addConstr((x.prod(costs) <= budget), name='budget')
```

Gurobi 的 `Model. addconstr()` 方法定义了 Model 对象 `m` 的预算约束。

此方法的第一个参数 `x.prod(costs)` 定义了预算约束的LHS。 `<=` 定义了一个更少或相等的约束，并且 `budget` 是约束的RHS。
该约束条件是分配候选人以满足职位需求的总成本不能超过可用预算。

第二个参数是此约束的名称。

## 目标函数

目标函数类似于 RAP。目标中的第一项是职位的总匹配分数。在这个 RAP 的扩展中，可能不是所有的职位都被填满了;然而，我们要严厉惩罚这种可能性。为了达到这个目的，我们在目标函数中有第二项，它将所有职位岗位的缺口变量的总和乘以一个比较大的惩罚值 $M$。

目标功能类似于 RAP。目标中的第一项是职位的总匹配分数。在 RAP2 中，可能并非所有职位都已完成。因此，我们要严重惩罚这种可能性。为此目的，我们在目标函数中有第二项，它求和所有职位上的差距变量的总和，然后乘以一个较大的惩罚值 $M$。

注意，匹配分数的最大值是100，而我们给$M$的值是101。$M$ 值背后的基本原理是：巨大的差值大大降低了总匹配分数值。

因此，目标函数是最大化职位的总匹配分数减去差距变量等于1的惩罚值。
$$
\max \; \sum_{j \; \in \; J} \sum_{r \; \in \; R} s_{r,j}x_{r,j} -M \sum_{j \in J} g_{j}
$$

```python
# 因未提供职位空缺而受到的惩罚
M = 101
```

```python
# 目标：最大化职位的总匹配分数
# 空缺职位将受到惩罚
m.setObjective(x.prod(scores) - M*g.sum(), GRB.MAXIMIZE)
```

```python
# 运行优化引擎
m.optimize()
```

    Gurobi Optimizer version 9.1.0 build v9.1.0rc0 (mac64)
    Thread count: 6 physical cores, 12 logical processors, using up to 12 threads
    Optimize a model with 7 rows, 12 columns and 30 nonzeros
    Model fingerprint: 0xf3c6f8c8
    Variable types: 3 continuous, 9 integer (9 binary)
    Coefficient statistics:
      Matrix range     [1e+00, 3e+00]
      Objective range  [1e+01, 1e+02]
      Bounds range     [1e+00, 1e+00]
      RHS range        [1e+00, 5e+00]
    Presolve time: 0.00s
    Presolved: 7 rows, 12 columns, 30 nonzeros
    Variable types: 0 continuous, 12 integer (12 binary)
    Found heuristic solution: objective 52.0000000

    Root relaxation: objective 1.350000e+02, 4 iterations, 0.00 seconds

        Nodes    |    Current Node    |     Objective Bounds      |     Work
     Expl Unexpl |  Obj  Depth IntInf | Incumbent    BestBd   Gap | It/Node Time

         0     0  135.00000    0    2   52.00000  135.00000   160%     -    0s
         0     0  121.66667    0    7   52.00000  121.66667   134%     -    0s
         0     0     cutoff    0        52.00000   52.00000  0.00%     -    0s

    Cutting planes:
      Gomory: 1
      GUB cover: 1
      RLT: 1

    Explored 1 nodes (7 simplex iterations) in 0.03 seconds
    Thread count was 12 (of 12 available processors)

    Solution count 1: 52

    Optimal solution found (tolerance 1.00e-04)
    Best objective 5.200000000000e+01, best bound 5.200000000000e+01, gap 0.0000%

目标函数的定义包括不安排职位的惩罚。然而，我们感兴趣的是当所有的职位不被填满时的最优总匹配分值。为此，我们需要使用匹配分值 $s_{r,j}$ 和分配决策变量 $x_{r,j}$ 计算总匹配分值。

```python
# 根据分配变量计算总匹配得分
total_matching_score = 0
for r, j in combinations:
    if x[r, j].x > 1e-6:
        print(x[r, j].varName, x[r, j].x)
        total_matching_score += scores[r, j]*x[r, j].x

print('Total matching score: ', total_matching_score)
```

    assign[Joe,Tester] 1.0
    assign[Monika,JavaDeveloper] 1.0
    Total matching score:  153.0

### 分析

回想一下，预算是$\$5,000$，与分配三个候选人相关的总成本是$\$6,000$。这意味着没有足够的预算来分配我们拥有的三种候选人。因此，Gurobi 优化器必须选择两个候选人来满足职位需求（放着一个职位没人干），并使总匹配分数最大化。

最大匹配的两个分数分别是80%( Joe-> 测试人员)和73% (Monika -> Java开发人员)。最低的分数是13%(建筑师职位的Carlos)。

将 Joe 分配给测试人员的职位，将Monika分配给Java开发人员的职位，而没有人分配给架构师的职位需要花费 $\$5,000$，并且产生的匹配总分为153。这是由 Gurobi 优化器找到的最优解决方案。

Copyright © 2020 Gurobi Optimization, LLC

翻译 By Arvin Xu