Friday, April 20, 2012

K Best Solutions

Someone recently asked me how to find the $K$ best solutions to an optimization. It's a fairly common question, and I've suggested methods in the past, but this time I decided to do a little experimenting to see if what I had in mind was appropriate.  I'll share some results in this post.

First, it's fair to ask when the question is even appropriate.  If your problem has continuous variables (for instance, a linear program), forget about it.  Barring a few unusual (if not pathological) cases I won't attempt to enumerate here, if a continuous problem has an optimal solution, there will typically be uncountably many feasible points in a neighborhood of the optimum with objective values arbitrarily close to optimal.

This also typically holds for problems with a mix of discrete and continuous variables; there may be a single combination of values of the discrete variables that leads to an optimal solution, and perhaps a clear "second best" set of values for the discrete variables, but for each combination of the discrete variables there is liable to be an uncountable number of ways to fill in the continuous variables giving a solution with objective value arbitrarily close to the best given those discrete values.  So we can restrict the question to problems where all variables are discrete, or to mixed problems if by "second best", "third best" etc. we mean second, third, ... best choices for the discrete variables.  Put another way, if we have a mixed problem (maximizing $f$) with optimal solution $x=x_0$ (discrete), $y=y_0$ (continuous) and objective value $f=f_0$, we can ask for a solution $(x,y)=(x_1,y_1)$ with $x_1\neq x_0$ and objective value $f=f_1$ such that no feasible solution $(x_2,y_2)$ with $x_0\neq x \neq x_1$ has objective value $f_2$ such that $f_1<f_2\leq f_0$, while recognizing that there could be a feasible solution $(x_0,y^*)$ with objective value $f_1 < f_0-\epsilon < f(x_0,y^*) \leq f_0$ for arbitrarily small positive $\epsilon$.

Second, context plays a role here.  A common reason to seek "runner-up" solutions is that the analysis of the model will be presented to a human decision maker, who wants options.  Decision makers, with some justification, do not always take well to being handed a single solution and told "this is optimal, you need to do this".  If the idea is to present options, then you may not need the $K$ best solutions; you may just need the best solution and $K-1$ "good" alternatives.  This can alter the method used to find the alternatives.  In fact, after finding one optimal solution, you may want to put a premium on "diversity" over objective value (while stipulating that a solution with a poor objective value should not be included).  Details of how to do that will have to wait for another day.  This post will be too long as it is.

So here are three possible approaches to finding the $K$ best solutions to a purely discrete problem, specifically an integer program (IP).  They all rely on having a good solver program, one that represents current technology.

1. Use the solver's solution pool. 

"Solution pool" is a phrase used by CPLEX.  Not every solver has the equivalent of a solution pool, but given the competitiveness of the solver market, my guess is that a number of other solvers have something similar.  Traditional branch-and-bound/branch-and-cut algorithms delete nodes from the search tree as soon as they are pruned, either due to infeasibility or due to having an inferior node bound.  Solution pools retain either feasible but suboptimal nodes or at least those with demonstrated integer solutions.  Various parameters let you control which solutions are retained.  CPLEX allows you to modulate their solution pool to encourage diversity (mentioned above) in the pool of retained solutions.

CPLEX in fact provides three ways of using the solution pool.  If you simply solve the problem, by default CPLEX will retain some of the incumbents found along the way.  Alternatively, you can use a populate method to fill the pool with possible solutions, or solve the problem and then invoke populate.  Simply tracking solutions found along the road to the optimum will typically prove unsatisfactory.  In fact, if the optimum is found early in the search (usually a good thing), you may not get any incumbents besides the optimum.  So I ignored that approach in the experiments and tried both populate alone and solve followed by populate.  Results for those two approaches were identical in all test runs, but I would not be willing to bet that they are always identical.  The nature of the test problem (described below) probably was a major reason why populate and solve-populate produced identical results.

2. Use an incumbent callback to track and reject solutions. 

Most contemporary solvers provide callbacks in their APIs.  One type of callback lets you inspect a proposed new incumbent solution before accepting it.  In the past, the method I've suggested for finding the $K$ best solutions to an IP is to use an incumbent callback to store the new solution, if it is one of the $K$ best found so far, and then reject it, forcing the solver to continue.  Since every solution is rejected, the solver eventually exhausts the search tree.  It will either declare the problem infeasible or point to a thoroughly suboptimal solution and call it optimal (because we lied to it), at which point the $K$ solutions currently being stored are the $K$ best.

Note that the incumbent callback should not reject every incumbent it sees.  Once there are $K$ solutions stored, if the solver proposes a solution with objective value inferior to the worst of those stored solutions, the incumbent callback should let the solver accept the solution.  That will move the bound and help the solver prune other nodes that have no chance of producing a nearly-best solution.

My experiments pointed out one wrinkle that I had not previously considered.  When you reject a solution, that may not be the last time you see it.  If the solution was found by a heuristic at some node, a different heuristic may find it again, or it may pop up as the solution to the continuous relaxation of some node in the tree.  Therefore, if you inspect only the objective value of each incumbent, you may end up with duplicate solutions in your final list (and thus fewer than $K$ distinct solutions).

During my experiments, I tried turning off all of CPLEX's heuristics (including "probing"), and duplicates still occurred.  This may mean I missed a heuristic somewhere, but there is another possible explanation.  Suppose that the continuous relaxation of a node produces an integer-feasible solution (incumbent), which you reject.  The solver now has to decide whether to prune that node or branch on it.  My understanding (obtained anecdotally) is that in this situation CPLEX will look for a branch callback to guide its branching decision and, failing to find one, will branch on an arbitrarily chosen integer variable.  I suspect that means that the solution you just rejected will remain feasible in one of the child nodes and, thus, will crop up again.

So, if you use an incumbent callback, it needs to inspect not just the objective value but also the values of the decision variables.  If the solution is good enough to displace one of the $K$ solutions already stored (or if you have not yet reached $K$ solutions), and if its objective value is distinct from those of the stored solutions, it is clearly a new solution; but if its objective value matches one of the stored solutions, you need to look at the decision variables to ensure this is not a rerun.

Update: I learned (the hard way) that to use either this approach or the next one (incumbent callback with solution injection), it is necessary to turn off both dual presolve reductions (set the CPLEX Presolve parameter to 1, which is primal reductions only) and the symmetry breaking feature (set the Symmetry parameter to 0). During presolve, either of those things can by design make changes to the model that will cut off feasible solutions recognized by the presolve logic as suboptimal -- but which might be among the $K$ best. Using the solution pool at intensity 4 causes CPLEX to make these changes for you. An explanation (in a different but related context) is posted in this IBM support document. Thanks to Dr. Ed Klotz for clearing this up for me.

3. Use an incumbent callback with solution injection.

Let's consider what happens, in the previous approach, when you already have $K$ solutions stored and a new solution is found that is better than the worst of them.  The worst of those stored solutions will be discarded.  Call its objective value $f$.  We now know that none of the ultimate $K$ best solutions will have an objective value worse than $f$, but the solver does not know that.  So the solver may waste time exploring nodes whose bounds are inferior to $f$.  We can avert that wasted time by belatedly accepting this solution (which the incumbent callback had previously rejected).  In the case of CPLEX, we can store this solution somewhere in program memory and use a heuristic callback to inject it as a new incumbent, thus moving the bound by a safe amount.


I put these three approaches to the test using a 0-1 knapsack problem.  The objective was maximization, so in the results below, larger objective values are preferable.  Test were done using CPLEX 12.4, on a quad core PC.  A few disclaimers are in order before diving into the results:
  • Results using CPLEX may not generalize to other solvers.
  • Results from a 0-1 knapsack problem may not generalize to other problems.
  • Anyone who expects some sort of consistency or monotonicity from computational results involving integer programs would do well to heed Emerson.
  • Solution time is not a monotonic function of number of nodes processed.  Processing a child node after a parent is frequently cheaper than jumping to some unrelated node (due to the number of dual simplex iterations required).  In some cases, CPLEX processed only one node (the root) but took an order of magnitude longer doing so than it did processing a large number of nodes with different parameter settings.  I suspect this may be due to time spent probing, but I have no concrete evidence to support that suspicion.
  • Your mileage WILL vary.
In the interests of conserving space, I will report results as nodes processed and the objective values of the final set of solutions.  Solution times did not vary much.  Most runs had negligible times (under one second), and the longest runs took less than nine seconds.

The second and third approaches listed above (incumbent solution with or without injection of discarded solutions) require no parameters.  The solution pool approach, in contrast, involves several parameters.  The solution pool capacity (number of solutions retained) was set to $K$.  There is a solution pool intensity parameter, an integer from 0 to 4, where 0 (the default) means CPLEX chooses, and 1 through 4 represent increasing intensity levels.  In all trials, the results for 0 and 2 were identical, so apparently CPLEX consistently defaulted to level 2.  There is also a population limit parameter (default 20), which limits the number of integer solutions CPLEX generates during the populate method.  I tested both the default value (20) and (200), but results might differ if other (in particular, higher) values were used.

I ran the binary knapsack model with the number of items ($N$) set to either 20 or 30 and the number of solutions to retain ($K$) set to either 3 or 5.  For the solution pool methods, I tried all possible combinations of populate or solve-populate, population limit 20 or 200, and intensity in $\{0,\dots,4\}$.  To conserve space, I will report just the best solution pool result (the result with the highest objective value for the suboptimal solutions) for each population limit, with the limit and intensity in parentheses.  There was no difference, at any population limit/intensity combination, between populate and solve-populate.  The second and third methods, using callbacks, are labeled "reject" and "inject" in the table.

\(N\) $K$ Method Nodes Results
20 3 pool (20, 3) 211 2065, 2073, 2077
pool (200, 1) 112 2073, 2076, 2077
reject 59 2076, 2076, 2077
inject 56 2076, 2076, 2077
20 5 pool (20, 3) 221 2048, 2050, 2065, 2073, 2077
pool (200, 3) 5647 2065, 2073, 2076, 2076, 2077
reject 157 2072, 2073, 2076, 2076, 2077
inject 112 2072, 2073, 2076, 2076, 2077
30 3 pool (20, 1) 1 6878, 6879, 6883
pool (200, 1) 145 6878, 6879, 6883
reject 100 6878, 6879, 6883
inject 109 6878, 6879, 6883
30 5 pool (20, 1) 1 6862, 6872, 6878, 6879, 6883
pool (200, 1) 73 6866, 6872, 6878, 6879, 6883
reject 142 6877, 6878, 6878, 6879, 6883
inject 153 6877, 6878, 6878, 6879, 6883

There are few generalizations I can take away from this.   Every approach found the optimal solution.  The default intensity setting for the CPLEX pool approaches was never best (but there is no consistency in which setting was preferable).  In only one case ($N=30,\ K=3$) was I able to find the actual $K$ best solutions using a pool method.  It is possible that setting the population limit much larger would do the trick, or that there is some other parameter setting that I simply missed.  Injecting discarded solutions in the third approach did not have a consistent impact: sometimes it increased the node count, sometimes it decreased the node count.  The node count itself is deceptive, in that the instances where only one node was reported by CPLEX tended to have the longest execution times.

Code (Java, very unpolished) and results (spreadsheet) are available by request.

Addendum: responding to Pratim's question, I posted a follow-up doing the same thing in AMPL.

Thursday, April 12, 2012

Excluding a Solution

This is another frequently asked question in OR forums: How does one exclude an otherwise feasible solution from consideration in a mathematical programming problem? The answer is apparently well known to some but not as widely disseminated as one might hope.  I'll treat four cases. Note that, in all cases, what I say applies to both linear and nonlinear problems.

All variables divisible (continuous)

Short answer: you cannot. Excluding a single point from the feasible region renders it non-convex (or, if the point is an extreme point, at least not closed). That is the kiss of death for conventional optimization algorithms. Metaheuristics may still work, though.

All variables binary

This case is easy. Let $x \in \{0,1\}^n$ be your vector of decision variables, and let $\bar{x}$ be the solution you wish to exclude. Just add the constraint $$\sum_{i: \bar{x}_i =0}x_i + \sum_{i:\bar{x}_i =1}(1-x_i)\ge 1.$$ The only solution in $\{0,1\}^n$ that violates it is $x=\bar{x}$.

All variables general integer

This one is trickier. Let $x \in \mathbb{Z}^n$ be your vector of variables. If you are only going to exclude a single solution $\bar{x}$, and if $x$ is bounded (say $L_i\le x_i \le U_i$, $i=1,\dots,n$), you can introduce binary variables $y,z\in \{0,1\}^n$ of the same dimension as $x$, for a total of $2n$ new binary variables. Now add the constraints \begin{eqnarray*}x_i & \ge & \bar{x}_i+1+(L_i-1-\bar{x}_i)y_i \qquad \forall i \\x_i & \le & \bar{x}_i-1+(U_i+1-\bar{x}_i)z_i \qquad  \forall i\\ \sum_{i=1}^n (y_i+z_i) & \le & 2n-1.\end{eqnarray*} The only way $x=\bar{x}$ is if $y_i=1=z_i \forall i$, which the last constraint precludes.

On the other hand, if you plan to exclude a sequence of solutions, this gets prohibitively expensive in terms of the number of binary variables and number of constraints being added. It is probably more efficient approach simply to recode x in terms of binary variables. That is, for each $i=1,\dots,n$ write $$x_i=y_{i0}+2y_{i1}+\dots+2^ky_{ik_i}$$ where $k_i=\left\lceil{U_i}\right\rceil$ and the $y_{ij}$ are all binary. (For simplicity, I'm assuming $L_i\ge 0$ here, but the adjustment if $L_i<0$ is easy.) You now have all variables binary, which reduces to the previous case.

A mix of integer and divisible variables

We're back to being screwed. You can use one of the tricks above, applied to just the integer variables, to exclude a single solution and all other solutions with the same projection onto the subspace of divisible variables. Excluding just the one solution, however, cannot be done.

Sunday, April 8, 2012

Resizing My Windows Partition

My laptop is dual boot -- Windows 7, Linux Mint 11 (Katya) -- and I ran out of space on the Windows partition. (Microsoft has never seen a disk drive it wasn't happy to fill up for you.) As it turns out, alleviating this was child's play (well, if your child is pretty advanced) once I figured out one little thing ... which I record here, since I'll not doubt forget it and then have to repeat the process.

I booted my laptop from the Katya CD.  Like all Mint distributions, you can run Linux from the CD, which allows you to screw with the hard drive. Mint comes with GParted, the Gnome partition editor, which deserves every accolade that anyone has ever thrown at it. My hard drive started with a small boot partition, followed by the Windows partition (NTFS), and then one extended partition for Linux containing three logical partitions (home, swap and the main partition).  All I had to do was:
  1. Shrink the main partition (which had the most free disk space) to create some unallocated space.
  2. Move the main partition all the way to the right.
  3. Move the swap partition all the way to the right. The unallocated space was now at the beginning of the extended partition.
  4. Shrink the extended partition to give up the unallocated space (placing it between the NTFS partition and the extended partition.
  5. Expand the NTFS partition to soak up the free space.
  6. Apply all operations.
The sticking point, which I discovered only by clicking anything that couldn't outrun me, was that I needed to right-click the swap partition and select "Swapoff" in order move stuff around within the extended partition. When all was said and done, I used "Swapon" to turn the swap partition back on.

Once that was done, I booted into Windows, which immediately ran chkdsk to square things away.  I rebooted into Windows, and all looks good.

Actually, the first time I did this, GParted showed the correct (new) partition scheme, but when I got into Windows it showed the old partition size.  Back to Mint and GParted, and sure enough, the old partition table was there. I'm not sure whether I screwed something up. The first time chkdsk ran, it rebooted when my back was turned, and so booted into Mint (the default) rather than Win 7. I don't know if that was the problem, or if somehow the modified partition table was not written the first time around.  Anyway, it's all good now.

AppFlower Tutorial Done

Following up on yesterday's post, I've completed the tutorial for AppFlower in which one builds a customer relationship management (CRM) system (or at least the framework for one). Along the way, I bumped into a few "gotchas" which I'll document here. I don't think we'll end up using AppFlower Studio, but not because of the few bugs I encountered (all of which seem to have work-arounds).  AppFlower is based on Symfony, and I think our target applications would require a deeper understanding of Symfony than I'm willing to invest in (especially since our developers prefer to use Microsoft's

Anyway, herewith my notes on the AppFlower CRM tutorial (in somewhat random order).
  • There are minor discrepancies between the text (including images) and what you see in AppFlower Studio, possibly due to updates in Studio since the tutorial was written. They're harmless.
  • The tutorial states that you can preview a form and use the preview to enter data into the database. I found the previews to be non-functional (neither the submit nor the reset button did anything).
  • In fact, even when running the application, the reset buttons on forms did nothing (in particular, did not clear the form). I have no idea why.
  • Some of the List views (in particular, listDeals) caused database errors when used, and prevented the dashboard from displaying. I'm not 100% certain, but I'm pretty sure this is tied to the presence of foreign keys in the models. The tutorial creates the foreign key relationship first (while building the model), then creates the form using the drag-and-drop interface to select fields. I found two work-arounds for the database errors. One is to create the model, including the foreign key fields, but not specify the relationships until after the list views are created. The other is to specify the relationship early (as in the tutorial), use drag-and-drop to create a list view containing everything but the foreign key fields, then add those fields manually in the widget inspector (right hand panel).  I have no idea why the work-arounds worked, but they did. Curiously, edit views for models with foreign keys worked just fine with no tweaking.
  • I discovered that if you return to an existing model containing foreign keys and delete a relation (not the entire field, just the relation), save the model, go elsewhere, then return to the model, the relation is back. The only cure for zombie relations that I found was to delete the entire field, then recreate the field without the relation.
  • The tutorial has you create links to forms on the desktop (which work), and to specify "icon-plus" as the icon. No icon was displayed on the desktop for any of those links (nor is there one in the screenshot in the tutorial).

Saturday, April 7, 2012

AppFlower + VirtualBox + Win7: The Adventure Begins

As noted in my last post, I'm trying to get an AppFlower development environment running.  My latest attempt is on my home PC (AMD-64 quad-core CPU), in a Windows 7 partition with lots of disk space.  AppFlower provided a preconfigured virtual machine that should work inside Oracle's VirtualBox. The VM file is a 1 GB download that inflates to 4 GB when unzipped, but it represents a 20 GB virtual disk drive (and thus may grow).

Installation is carefully documented, with both step-by-step text instructions and a short video.  Unfortunately, the video and the text do not always agree, and apparently sometimes both are wrong. If my tests of AppFlower convince me it fits our project needs, I'll be repeating this installation process, so I'd better document the issues here.
  • The written instructions warn Windows users to enable IO APIC. The video instructions (perhaps not for Windows users?) omit this. It seems to be important, at least on Win 7.
  • In the video, when playing with the VM's settings, the demonstrator explicitly disables the boot from USB and boot from CD options, so that boot from hard drive is the only choice. The written instructions are silent about this. I suspect it makes no difference, but better safe than sorry, so I followed the video.
  • The video uses host-only networking of the VM, while the written instructions suggest bridged network first, NAT as a fall-back. Something I found in a response to a support question suggested that AppFlower Studio may go off on the Internet and download something when you create a new project. I suspect that cannot happen with host-only, although I'm not positive. On the other hand, I'm unable to connect my browser to the VM if it uses NAT.  (I can connect with both host-only and bridged.) So bridged wins.
  • Neither the video (at least not that I noticed) nor the text instructions says anything about turning off audio support in the VM settings. So I didn't (at first), and got warning messages while booting the VM. The warnings are harmless, but I think it best to turn off audio anyway.
  • After installing and signing in, you're greeted by a splash screen. The documentation shows four quick link buttons, the first two of which can be used to create a new project or open an existing project. I see neither of those on my splash screen, just the last two buttons. Fortunately, equivalent functionality is available from the Studio BETA button in the top left after you get past the splash screen.
  • The instructions to create a project show six steps. In fact, AppFlower Studio only lists four steps (steps 1, 3, 4 and 6 of the five shown, which are numbered 1-2-3-4 in the Studio beta). The disk location is automatically selected (a somewhat cryptically named directory is created on the VM), and the database is set up using a rather funky database name and automatically generated credentials. It will be interesting to see how hard it is, if we get to the point of deploying an application, to change the database settings.
Now the critical part. After sorting out all settings stuff above, and logging into the Studio, I attempted to create a new project named CRM (following the  "Practical AppFlower" tutorial). Again, the tutorial shows six steps to create the project whereas the software actually only has four. The last step is passive; click Save Project and Studio sets everything up for you, including the virtual host. Except it didn't. Instead, I got a somewhat less than helpful message that the project was not created because an error occurred (with no indication what the error was).

Time for some serious groping in the dark. AppFlower Studio includes (in the Tools menu) an easy way to open a console. The console, sadly, contained no error messages. Within the console, certain special commands can be run. Two of those are documented in the section labeled "AppFlower Studio Tasks - afs". I tried the first (afs fix-perms) thinking that perhaps Studio was unable to create the project directory due to munged disk permissions. It did not appear to help: the same nonspecific error message occurred when I tried to create the CRM project. So I tried the second (afs insert-diff-sql). It reported that it found no differences, but after running it project creation worked. If I have to go through this again, it's probably a good idea to run both commands. The first is at worst harmless, and it's possible that both were required to fix whatever was going wrong.

After creating the project, I was given a new port number. Pointing the browser at that port (on the same IP address that I using for the VM) opens the new project. Interestingly, the Studio BETA button in the new project has no option to create another project, nor to open other projects. The Studio BETA button in the "playground" (the instance I already had open) has both a create menu item and a recent projects menu item. (The new project did not appear under "recent projects" until I logged out and back in. I also changed the project name, in its settings dialog, before logging out of the playground and back in. I don't know if some tweak like that is necessary to make the playground take notice of the project.)

The next step is to complete the CRM tutorial and see if everything works.

Wednesday, April 4, 2012

Adventures with WAMP and XAMPP

I'm experimenting with AppFlower -- a rapid (?) application development (RAD) tool based on PHP and the Symfony framework -- as a possible solution for a non-trivial web application project in my college.  AppFlower/Symfony supports the MVC pattern for software development, which fits with our current practices.  I'm cautiously optimistic that it will work for us, and if so I may report back here.

The plan was for me to install it on my laptop this afternoon and then go through a tutorial or two.  I already have it running on my office PC (Linux Mint) in a VirtualBox virtual machine, but I prefer to use my laptop for development, since (a) it's faster than my somewhat antiquated office PC and (b) it's portable.  Also, I'll be working with one or more of our IT people, who are all slaves to Windows.  My laptop dual-boots Windows 7, so I figured I'd use the Win 7 side as my development environment.

As someone (Napoleon?) once said, the first casualty in every battle is the plan.  It took me 4.5 hours just to install on my laptop.  I don't need a virtual machine to run AppFlower under Windows, just a LAMP stack (give or take the "L"), and their website gives detailed instructions on how to install AppFlower to work with WampServer (a.k.a. WAMP), a very nice way to load an Apache-MySQL-PHP development stack on Windows.  I already had WAMP installed and running correctly with other applications, so this would be easy, right?

Almost.  All the configuration bits seemed to go smoothly until the last step, which is to run a Symfony configuration script via the PHP command line interface.  There were a couple of hiccups here:
  • the script turned out to be a bash script, which Windows had no idea how to handle (solution: find the php.exe file in the WAMP directory hierarchy -- it's not added to the system command path -- and run it explicitly against the setup script); and
  • prior to running the script, I was told to tweak a setting in the php.ini file that might otherwise cause AppFlower some problems -- but there are actually two php.ini files, one in the Apache directory and one in the PHP directory (solution: once I realized this, I tweaked both the same way).
Then there was one major problem: part way through the configuration script, the PHP command line (CLI) program died (known to Windows as "APPCRASH").  Poking around, the culprit seemed to be a module named php5ts.dll.  I became very intimate with Google's search engine looking for explanations and fixes.  A number of people have reported a variety of APPCRASHes involving PHP and Symfony or WAMP or neither of the above.  Many traced back to having two different versions of PHP installed on the same machine.  I don't; the WAMP copy was the only copy.  Some were allegedly cured by a fresh download and install (alleging a corrupt initial copy).  That did not fix mine. Someone suggested adding '-c . ' to the parameter strings of a bunch of lines in a couple of .php files.  Once again, that did not help me.

Well, no point in spending another four hours recounting where that first four hours went. Bottom line, nothing I did prevented the APPCRASH, so I finally gave up on WAMP, uninstalled it, and installed the Windows version of XAMPP.  The instructions for installing AppFlower under WAMP also work, with minimal modification, for installing it under XAMPP.  Some of the changes are quite minor (WAMP's control panel gives you a direct route to edit Apache's httpd.conf file, while in XAMPP I had to find it and open it manually in an editor).  The only sticking point -- and this is strictly a consequence of my brain being fried by the time I got to it -- was that the virtual host set up in Apache for the AppFlower Studio app did not work.  This was a copy/paste operation (copy the Apache directives from the instruction page, paste into httpd.conf.  Well, with WAMP it's copy/paste.  With XAMPP it's copy/paste/edit, because (duh!) the paths are different (C:\wamp\www becomes C:\xampp\htdocs, assuming you use the default installation paths for both). Interestingly (to me), AppFlower Studio will not open without the virtual host declaration.  (I thought I could just give an explicit path to it in the URL.  No such luck.)  If I have to do this again (heaven forfend!), this page will hopefully remind me to fix the virtual host declaration.

So I seem to be up and running now, albeit too late to touch the tutorial today. XAMPP seems to be using a more recent version of PHP than WAMP was; perhaps that explains php.exe not crashing on the Symfony script, or perhaps it's something entirely different.

Oh, yes, note to self: the user name and password for both AppFlower Studio and the SeedControl demo that it comes with are both 'admin' ... which is not well documented, to put it mildly.

Update:  Part of the AppFlower installation process is to add a VirtualHost directive to XAMPP's Apache configuration, so that the "host" name studio.local (which I mapped to in the laptop's host file) points to the AppFlower web directory.  This worked, but too well: it masked XAMPP's own document root, to the point that the built-in administration interface was hidden. The explanation is found in Apache's instructions for name-based virtual hosts.  To avoid masking the original root, I need to add a second VirtualHost directive, using the original server name (in my case, localhost), point it to the original document root, and put it ahead of the one for AppFlower. (The AppFlower installation documents neglect to mention this.)

Update #2: I also need to insert a  NameVirtualHost directive in httpd.conf, ahead of the first VirtualHost directive.  Otherwise, the first VirtualHost (localhost for me) masks the second one (studio.local).

Update #3: Apparently, when installing on XAMPP and WAMP, AppFlower Studio needs to be told where to find the PHP executable. I fixed that, but I'm still getting error messages on most operations. They may be harmless, since the allegedly errant operation seems to have worked (possibly after manually refreshing the web page).

Update #4: I've given up on AppFlower with XAMPP/WAMP and may be having some success with VirtualBox (next post).