Custom Search

Tuesday, January 17, 2012

An analysis of FBS controversies: Part III, the solution

While the title is "the solution", this is, of course, my proposed solution. The problem to be solved is that the current system leaving "deserving" teams out of the playoff creates controversies.

Why a playoff is not the solution

A playoff would seem to be the obvious solution, but there are some real reasons not to change. Ignoring the financial interests, a real playoff system - whether it's 8 teams (the major conference champions plus wild cards) or 16 (all FBS conference champions plus wild cards) - changes the nature of the regular season. The more teams allowed into the playoffs, the less important - and hence less intense and less exciting - the regular season becomes.

While it's now a cliche, with the current system every game counts. If a team is in contention for a national championship, losing any game changes the nature of their season. It may be enough to take them out of the national championship picture. At the very least, they'll need help - in the form of other teams beating contending teams - in order to have a shot at the national championship.

The conference championship is a different thing entirely. The non-conference games aren't counted in conference standings - or only counted indirectly, in that the rankings are used as last resort tie-breakers, and the non-conference games count there. Losing a game to a conference opponent that's already got more losses than you doesnt't - by itself - change the nature of your season. If you win the rest of the conference games, you'll be tied with someone that you beat and hence hold the tiebreaker.

So a team can be completely out of the national championship race and still be a contender for a conference championship. A playoff system changes that because if you're in contention for a conference title, that puts you in contention for a national championship. This makes the regular season nothing more than a qualifier for the playoffs, and much less exciting.

The Flex Playoff

The goal is to preserve the "every game matters" nature of the regular season, while at the same time avoiding controversies when it comes to picking teams for the playoff.

The solution - to me, at least - seems obvious: let the number of teams in the playoff change, so that each year only the "deserving" teams are included. This means the format changes each year, but the bowl system is flexible enough to accommodate that.

To do this, take the top two teams in the rankings, and anyone with no more losses than the number two team, seeded by ranking. That eliminates controversies - at least so far as choosing the teams is concerned. It means that a team that loses a game needs help - in the form of all but one other undefeated team losing - to have a chance to get into the national championship game. It means the bias against the mid-majors will only lower their seeding; they'll get a chance to prove they deserve better on the field.

This is still using the regular season as a qualifier for the playoffs, but the level to qualify is the nearly the same as it is now, so the regular season is nearly as important as it is now. The format would be the same as an 8-team tournanemt, with teams facing a non-qualifying seed (i.e. seed #5 when only three teams qualified) get a bye. With fewer than 5 qualifiers, the first round is skipped and there's a four team tournamet. With only two qualifers, there's just a championship game.

So a flex playoff will have all the fairness desired of a regular playoff, in that no deserving team will be left out. It will be more fair than a playoff, in that no undeserving team will be allowed in. It will maintain the intensity of the regular season at the level it is now, because qualifying for a flex playoff requires the same record as qualifying for the championship game. It combines the best features of both systems.

Sunday, January 15, 2012

An analysis of FBS controversies: Part II, the analysis

Now that I have a tool that can report on how much controversy any given scheme would have, I can start looking at numbers!

Which seasons?

First, I have to decide the range of seasons to check. The earliest season I can use is 1872. See the writeup on the code for a discussion of why 1869 through 1871 can't be used. An argument could be made that I should start with "the modern era", whatever that is. Arguments can be made for years as early as 1906 and as late as 1985 - and many in between. I'm going to take the "more data is better" approach, and start with 1872.

Likewise, the last year to use is open to debate. Here, the issue is bowl games. Ideally, the data would use records from the regular season and possible conference championships, which are played the last day of the regular season. Unfortunately, the data source also includes bowl games. Before 1992, the bowl games were no better organized than the regular season. Conferences and bowls had contracts for teams to play in specific games. So you would get a selection of highly ranked teams playing each other apparently randomly, with little chance of the two highest ranked teams playing each other. While these games may well have changed which systems would have been controversial each year, I believe the effect will average out over the long term.

This changed in 1992, when various entities started trying to get the best two teams playing each other. There was limited success until 1999, when the current system - agreed to by the major conferences - was started. Because the system matched the top two teams in 1992, I'm going to stop with the 1991 season.

Majors vs. mid-majors

One of the controversial features of the current system is that the FBS schools are split into two groups, "majors" and "mid-majors". The major (aka AQ or BCS) schools get ranked above the mid-majors, even if they have more losses. The explanation for this is that schools in each group mostly plays schools in that group, so mid-major schools don't play major schools very often, and hence have an easier schedule. I purposely ignored that, because an undefeated mid-major team causes controversy, especially if there are no undefeated major teams. Again, the controversy the code finds may be different from the controversy the press sees, and again I expect that to average out over the long run.

And the numbers are


As you can see, there isn't a lot of difference to be seen between the three options. The championship game we have now seems to generate slightly less controversy, which is what we've experienced. Going to a plus-1 from what we have now would raise the controversy level back to almost the point we were at before.


Our current system with a single championship game is less likely to generate controversy than going to a plus-1 system.

Friday, January 13, 2012

An analysis of FBS controversies: Part I, the code

America's college Football Bowl Subdivision (FBS) has a history of controversy when it comes to picking a champion. And a history of proposed solutions. I've decided to examine how well those solutions would have worked if applied to previous seasons.

The football background

If you're a football fan, you can skip this section.

The FBS has one unusual property. Unlike every other organized college sport in the US, and every other nationally organized sport anywhere in the world, it has no clear cut way to pick a champion for each season. Instead, various groups - some as small as a single person - analyze the games played during the season and rank the teams according to that play, with the highest ranked team at the end of the season being crowned "champion". If the major groups agree with each other, there's a "consensus champion". If not, then you have a controversy.

People familiar with other sports might be amazed that there's ever a consensus. However, FBS teams play few enough games that winning every game in a season is possible, and it's not at all unusual for one or more teams to be undefeated in a season. It's also hard enough that it's not unusal if there are no undefeated teams at the end of a season.

So the most important criteria any ranking system considers is "number of losses and ties". (Football fans who decided to read this section are now saying "But what about non-AQ schools". For now, I'm going to ignore that, and I'll justify doing so in the sequel.) The other criteria are where the controvery comes in, as a case can be made that any two undefeated teams are equally good, and ditto for any two one-loss teams (provided they haven't played each other), etc.

The proposed solutions - short of an actual playoff, also to be discussed in the sequel - involve picking some small number of teams from the top to play each other. This system has actually been in use since the 1992 season to pick the top two teams. It doesn't seem to be any less controversial, and the formula for picking the two teams has been adjusted nearly continuously to try and deal with those controversies. A "plus-1" system gets a lot of talk, even though it has multiple definitions, but usually involves picking the top 4 teams.

The task

Since the FBS has a controversy any time either a clear winner fails to emerge, or - since the Bowl Coalition and it's succesors starting picking two teams for a championship game - if there's not a clear choice of two teams, the goal of the project is to see how often picking either 1, 2 or 4 teams (historical, current and "plus-1" approaches) who get a chance to play for the national championship would have done on previous seasons.

Deciding if there would have been controversy for a system is easy. If the last team to make the cutoff - either the first place team, the second place team or the fourth place team - has the same number of losses/ties as some team that doesn't get such a chance, the system with that cutoff has a conroversy for that season.

The code

Given that definition, the code is straightforward: for each season, get the records of all the teams, sort them based on losses/ties, and the check the records of the two teams on either side of each cutoff. This part presents the code for doing that.

So, let's start by getting the record for a season. Since that involves IO, the function that does it will work in the IO monad, giving us loadYear:

loadYear :: Int -> IO (Bool, Bool, Bool)
loadYear =
  liftM getYear . (>>= getResponseBody) . simpleHTTP . getRequest . makeURL
makeURL :: Show a => a -> String
makeURL year = "" ++ show year ++ ".htm"

makeURL is a helper function that just turns the integer year into a URL for the data on That url is fed to a series of functions from the Network.HTTP module to produce a page

This idiom - a series of functions separated by '.' - is common in Haskell code. '.' is the compose operator, producing a new function that is the result of applying the right function and then the left function. This idiom and an alternative are going to show up in a number of places.

The interesting part is the first two steps, which actually happen last. The step before them - simpleHTTP takes a string value and produces a value in the IO monad. The form (>>= getResponseBody) is a function that will apply getResponseBody - which also work in the IO monad - to the value in the monad, returning the result from getResponseBody. liftM getYear is similar, except that getYear (presented below) does not work in the IO monad. liftM applies it to the value in the monad, and returns it's result in the monad. I can't use liftM on getResponseBody because getResponseBody, unlike getYear returns a value in the monad.

I've just talked a lot about monads. If you're not familiar with them, you're probably wondering what I'm talking about. A monad is a class - by which I mean a group of functions that can be applied to members of the class - that functional programming languages use to hide plumbing of various types. In this case, the IO monad hides the state information for whatever IO is being done from. One of the properties of a monad is that it can be viral. If a function uses a value in some monad, the function has to return a value in that monad unless the monad includes a function to unwrap the value. Without that, while the using function can extract the value from the monad to pass it to functions that aren't in the monad (know as pure functions) and get a value back, the value of the function has to be in the monad. After all, if a function does IO, then any function that calls it also does IO.

So makeURL and getRequest are pure functions, returning pure values. simpleHTTP takes that value and does IO, so it returns a value in the IO monad. The next two functions - (>>= getResponseBody) and liftM getYear have to accept and return values in the IO monad.

The next group of functions are easier, I promise. They're all pure. getYear takes the string representing the page for a season, and maps it to a set of three booleans, indicating whether or not that seasons results would be a controversy for a poll, a championship or a plus one system.

getYear :: String -> (Bool, Bool, Bool)
getYear = controversy . sort . (\ (t, r) -> map (getData t) r) . getRows . getTable . parseTags

And we once again see that list of function compositions. parseTags is from the Network.HTTP module, and turns the string into a list of Tag String elements. getTable extracts the table from that list:

getTable :: [Tag String] -> [[Tag String]]
getTable = partitions (isOpenTag "tr") . drop 2 
           . takeWhile (not . isCloseTag "table")
           . dropWhile (not . isOpenTag "table")

And again I have a list of function compositions. dropWhile skips over elements that it's first argument says to - in this case, any element that is not a "table" open tag, takeWhile accepts elements that it's first argument says to accept - in this case, any element that is not a "table" close tag. drop just returns the list without it's first two elements, which are the "table" open tag and the text between it and the start of the first row. Finally, partitions (also from the network.HTTP module) turns the list of Tags into a list of lists of Tags, starting a new list at every "tr" open tag.

isOpenTag and isCloseTag are very similar, using haskell's argument matching facility to check for TagOpen or TagClose elements in the list, and then comparing the text in the tag to their first argument to make sure this is the open or close for the proper element:

isOpenTag :: String -> Tag String -> Bool
isOpenTag tag (TagOpen t _) = map toLower t == tag
isOpenTag _ _ = False

isCloseTag :: String -> Tag String -> Bool
isCloseTag tag (TagClose t) = map toLower t == tag
isCloseTag _ _ = False

The table from getTable is then passed to getRows. getRows is more complicated, in that it has to check the first row to see if this year has a column for ties (the rules changed in the 90s, making ties impossible). It returns a Boolean indicating whether or not there was a tie along with list of rows after the first one.

getRows :: [[Tag String]] -> (Bool, [[Tag String]])
getRows rs = (isTies (head rs), tail rs)
  where isTies r = maybe False (const True) (find (== TagText "Ties") r)

isTies is a helper function only visible in the getRows namespace. It runs code in the Maybe monad, which can either hold a value or not (i.e - is Nothing). This monad has an extraction function maybe that can return values outside the monad. It's arguments are a value to return if there is no value in the monad (i.e. - it's Nothing), a function to apply to the value if there is one, and the value to be checked. In this case, I return False if there was no "Ties" column, and use a function that always returns True if there is a "Ties" column.

The piping the maybe monad is used to hide is error checking. You can pass a value in the monad through a string of functions that return a value in the monad, and once one returns a Nothing the rest will be skipped, passing the Nothing to the top level without having to explicitly code tests for it at each step. I'm not taking advantage of that here, but would have if I'd wanted to treat missing pages as seasons with no teams (as in 1871).

The next step in the processing is to extract the controversy information for each season. That's done by (\ (t, r) -> map (getData t) r), an anonymous function that accepts the tie Bool and then maps getData t over the rows. getData t uses Haskell's automatic currying to create a function that already has the ties Bool and will process a row:

getData :: Bool -> [Tag String] -> (Int, Int)
getData True = (\gs -> (head gs, head $ tail gs)) 
               . map getLosses . take 2 . dropToLosses
getData False = (,0) . getLosses . head . dropToLosses

The two variants of getData are almost identical. Both start with dropToLosses, which partitions the list on data elements, then apply getLosses either to the first element (if there are no ties) or the first two (if there may have been ties) and finally puts the results in a tuple, using a 0 for ties if they weren't possible.

To summarize the list tweakings: I started with a list of Tag's, turned that into a list of lists, each sub-list being a row. The processed each sub-list through getData, which once again turns it into a list of lists - this time of data elements in the row.

dropToLosses is simple:

dropToLosses :: [Tag String] -> [[Tag String]]
dropToLosses = drop 8 . partitions (isOpenTag "td")

It does the partitioning, then drops the first eight sub-lists, representing things in the table before the losses column.

getLosses is almost as simple:

getLosses :: [Tag String] -> Int
getLosses = read . fromTagText . head . tail

It drops the first element, the "td" tag, then extracts the fist element after that, which is the text in the "td" tag, then uses read to convert that from a string into an integer.

The last step in getData is to create a triple of Bools, saying whether the first and second teams had the same record, whether the second and third had the same record, and whether the fourth and fifth had the same record. Because list indices start at 0, I have to subtract one from each value in the comparison:

controversy :: [(Int, Int)] -> (Bool, Bool, Bool)
controversy records = (records !! 0 == records !! 1,
                       records !! 1 == records !! 2,
                       records !! 3 == records !! 4)

genStats generates the stats, but starts by calling loadYear, putting it in the IO monad. genStats uses loadYear on a list of years, then calculates and returns the percentage of times each type of controversy occurs in that list of years:

genStats :: [Int] -> IO (Int, Int, Int)
genStats years = do
  pages <- mapM loadYear years
  return $ percents (foldl' counts (0, 0, 0) pages) 
                    (fromIntegral $ length pages)
    percents (a, b, c) l = (a % l, b % l, c % l)
    (%) a b = round $ 100 * a / b
    counts (a, b, c) (ab, bb, cb) = (minc a ab, minc b bb, minc c cb)
    minc x xb = x + if xb then 1 else 0

genStats gets the list of controversy triples via loadYear, then uses a quad of helper functions to calculate percentages. percents takes the triple of counts and the length of the list and uses % to generate the percentages. This is the one place where Haskell's type system gets in the way. It can't coerce the integer from length into a type that can be used with /, so it has to be explicitly coerced with fromIntegral. counts generates the actual counts, using minc to increment the current value of each count depending on whether or not there was a controversy for that count on this page.

I use makeYears to process the arguments. It takes a list of Strings, and returns a list of Ints. If the input list has only one element, it's value as an Int is wrapped in a list and returned. If it has two elements, they are taken from the list, turned into Ints, and the range from the first to the second is returned. Otherwise an error message is printed:

makeYears :: [String] -> [Int]
makeYears args =
  case length args of
    1 -> [read (head args)]
    2 -> (\l -> [head l .. head $ tail l]) $ map read args
    otherwise -> error "Arguments must be first and optional last year."

Finally, the main routine - which in Haskell as in other languages, is where compiled programs start - uses a chain of functions in the IO monad to get the results and print them. This time, the unusual function is makeYears, which isn't in the IO monad, but returns a value to genStats, which is.

main =
  getArgs >>= genStats . makeYears >>= uncurry3 (printf format)
    format = "Percentage of seasons with controversies using\n" ++ 
             "Poll: %d%%, Game: %d%%, Plus 1: %d%%\n"

That's the code. You can, of course, fetch it from the repository if you want to build it and see the results yourself. I'll be discussing that in the next entry.

Dealing with the first three years

This code should work for all football seasons from 1872 forward. There was no football played in 1871. The first season was played in 1869. There were fewer than five teams playing in 1869 and 1870. Dealing with them would require deciding if no teams in the lower slots should be counted as year played without controversy, or not counted at all, and the code modified to match.

In 1869, there were two one-loss teams, so a poll had a controversy, and a championship game didn't. This table also requires special handling, as the pre-season ranking column is missing.

In 1870, there there three teams, one undefeated, and two with one loss. The code would have counted this as no controversy for a poll, and a controversy for a championship. However, Princeton beat Rutgers and Rutgers beat Columbia, so the rankings for 1, 2 and 3 aren't controversial.

In 1871, there was no college football. Worse yet, that page generates an error instead of an empty list, so handling it would require special handling.

Since it would have taken three changes to include just two more seasons - and problematic ones at that - these are ignored.